diff --git a/.github/actions/setup-ocaml/action.yml b/.github/actions/setup-ocaml/action.yml index fc8c7886107..18026b37146 100644 --- a/.github/actions/setup-ocaml/action.yml +++ b/.github/actions/setup-ocaml/action.yml @@ -82,7 +82,8 @@ runs: run: | dependency_flags_key="${DEPENDENCY_FLAGS//--/}" dependency_flags_key="${dependency_flags_key// /_}" - key="$CACHE_PREFIX-$OS-3.8.0-$COMPILER-$dependency_flags_key-${{ hashFiles('*.opam') }}" + dependency_hash="${{ hashFiles('*.opam', '.github/opam-repository/repo', '.github/opam-repository/packages/**') }}" + key="$CACHE_PREFIX-$OS-3.8.0-$COMPILER-$dependency_flags_key-$dependency_hash" echo "value=${key//,/-}" >> "$GITHUB_OUTPUT" echo "setup-version=3.8.0" >> "$GITHUB_OUTPUT" @@ -104,6 +105,11 @@ runs: uses: ocaml/setup-ocaml@v3.8.0 with: ocaml-compiler: ${{ inputs.compiler }} + # Use the HTTP repository to prevent intermittent CI setup failures: + # the Git-backed default can race with detached Git maintenance while + # OPAM scans it (https://github.com/ocaml/opam/issues/7031). + opam-repositories: | + default: https://opam.ocaml.org opam-pin: false cache: false @@ -125,12 +131,21 @@ runs: env: DEPENDENCY_FLAGS: ${{ inputs.dependency-flags }} run: | + opam repository add rescript-overrides "$GITHUB_WORKSPACE/.github/opam-repository" read -ra dependency_flags <<< "$DEPENDENCY_FLAGS" + if [[ "$RUNNER_OS" == "Windows" ]]; then + export AR=x86_64-w64-mingw32-ar + export RANLIB=x86_64-w64-mingw32-ranlib + export STRIP=x86_64-w64-mingw32-strip + export NM=x86_64-w64-mingw32-nm + export DLLTOOL=x86_64-w64-mingw32-dlltool + export OBJDUMP=x86_64-w64-mingw32-objdump + fi opam install . "${dependency_flags[@]}" - name: Cache OPAM environment # Caches created by pull_request runs are scoped to that PR's merge ref - # and cannot seed other PRs or branches. Only pushes create shared caches. + # and cannot seed other PRs or branches, so only push runs save them. if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'push' uses: actions/cache/save@v6 with: diff --git a/.github/opam-repository/README.md b/.github/opam-repository/README.md new file mode 100644 index 00000000000..35e088bb9a0 --- /dev/null +++ b/.github/opam-repository/README.md @@ -0,0 +1,14 @@ +# ReScript opam overrides + +This repository contains narrowly scoped dependency fixes needed by CI before +they are available from the main opam repository. + +The Luv 0.5.14 override preserves the ARM64 musl compiler's +`-mno-outline-atomics` requirement while building vendored libuv. Without it, +the static executable fails to link because libuv refers to glibc's internal +`__getauxval` symbol. The patch should be contributed upstream and this +override removed once a fixed Luv release is available. + +Luv 0.5.14 also vendors libuv 1.48.0. Evaluating and contributing an update to +a current libuv 1.x release is a separate upstream follow-up; changing the +vendored library is intentionally outside the OCaml rewatch port. diff --git a/.github/opam-repository/packages/luv/luv.0.5.14/files/arm64-musl-outline-atomics.patch b/.github/opam-repository/packages/luv/luv.0.5.14/files/arm64-musl-outline-atomics.patch new file mode 100644 index 00000000000..674f1b37c4c --- /dev/null +++ b/.github/opam-repository/packages/luv/luv.0.5.14/files/arm64-musl-outline-atomics.patch @@ -0,0 +1,20 @@ +--- a/src/c/dune ++++ b/src/c/dune +@@ -82,8 +82,14 @@ + (action (progn + (bash "cp -r vendor/configure/* vendor/libuv/") + (chdir vendor/libuv (progn + (bash +- "sh configure --host `ocamlc -config | awk '/^host:/ {print $NF}'` \ +- 'CC=%{cc}' CFLAGS=-DNDEBUG --silent --enable-silent-rules") ++ "architecture=`ocamlc -config-var architecture` ++ c_compiler=`ocamlc -config-var c_compiler` ++ extra_cflags= ++ case \"$architecture:$c_compiler\" in ++ arm64:*musl*) extra_cflags=-mno-outline-atomics ;; ++ esac ++ sh configure --host `ocamlc -config | awk '/^host:/ {print $NF}'` \ ++ 'CC=%{cc}' CFLAGS=\"-DNDEBUG $extra_cflags\" --silent --enable-silent-rules") + (ignore-outputs (bash + "$([ '%{os_type}' = Unix ] && echo %{make} || echo make) V=0 -j 4 \ + -o aclocal.m4 -o Makefile.in -o configure \ diff --git a/.github/opam-repository/packages/luv/luv.0.5.14/opam b/.github/opam-repository/packages/luv/luv.0.5.14/opam new file mode 100644 index 00000000000..e64c76a6dd5 --- /dev/null +++ b/.github/opam-repository/packages/luv/luv.0.5.14/opam @@ -0,0 +1,42 @@ +opam-version: "2.0" +name: "luv" +version: "0.5.14" +synopsis: "Binding to libuv: cross-platform asynchronous I/O" +description: """\ +Luv is a binding to libuv, the cross-platform C library that does +asynchronous I/O in Node.js and runs its main loop. + +Besides asynchronous I/O, libuv also supports multiprocessing and +multithreading. Multiple event loops can be run in different threads. libuv also +exposes a lot of other functionality, amounting to a full OS API, and an +alternative to the standard module Unix.""" +maintainer: "Anton Bachin " +authors: "Anton Bachin " +license: "MIT" +homepage: "https://github.com/aantron/luv" +doc: "https://aantron.github.io/luv" +bug-reports: "https://github.com/aantron/luv/issues" +depends: [ + "base-unix" {build} + "ctypes" {>= "0.14.0"} + "dune" {>= "2.7.0"} + "ocaml" {>= "4.03.0"} + "alcotest" {with-test & >= "0.8.1"} + "base-unix" {with-test} + "odoc" {with-doc & = "2.4.0"} +] +build: ["dune" "build" "-p" name "-j" jobs] +patches: ["arm64-musl-outline-atomics.patch"] +extra-files: [ + [ + "arm64-musl-outline-atomics.patch" + "sha256=c15c4c4d34c9b545197c5160e19c69a128cc186b65a54e0e9a779c2543528a4f" + ] +] +dev-repo: "git+https://github.com/aantron/luv.git" +url { + src: + "https://github.com/aantron/luv/releases/download/0.5.14/luv-0.5.14.tar.gz" + checksum: + "sha256=8e01b4a50c8876cdd98d8e245c0687c4dc4d883aed161ad9c5ace1fb1fdaae99" +} diff --git a/.github/opam-repository/repo b/.github/opam-repository/repo new file mode 100644 index 00000000000..013b84db617 --- /dev/null +++ b/.github/opam-repository/repo @@ -0,0 +1 @@ +opam-version: "2.0" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c26588ac4b0..7826ca538db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: with: compiler: ${{ matrix.ocaml_compiler }} os: ${{ matrix.os }} - cache-prefix: opam-env-v8 + cache-prefix: opam-env-v9 - name: Compiler build state key id: compiler-build-state-key @@ -212,8 +212,61 @@ jobs: run: echo "C:\Program Files\Git\bin" >> $GITHUB_PATH shell: bash - - name: Run rewatch tests - run: ./rewatch/tests/suite.sh rewatch/target/release/rescript + - name: Run OCaml rewatch parity tests + timeout-minutes: 20 + run: | + bash rewatch-ocaml/tests/check_rust_test_coverage.sh --require-complete + bash rewatch-ocaml/tests/check_canonical_test_coverage.sh + bash rewatch-ocaml/tests/check_config_acceptance.sh \ + packages/@rescript/${{ matrix.node-target }}/bin/rescript-rust.exe \ + packages/@rescript/${{ matrix.node-target }}/bin/rescript.exe + bash rewatch-ocaml/tests/check_command_validation.sh \ + packages/@rescript/${{ matrix.node-target }}/bin/rescript-rust.exe \ + packages/@rescript/${{ matrix.node-target }}/bin/rescript.exe + shell: bash + + - name: Run OCaml rewatch interactive output tests + # Terminal rendering depends on the host OS, not the CPU architecture. + # Use the faster ARM runners for one Linux and one macOS check. Windows + # does not provide a reliable native equivalent of the `script` PTY. + if: matrix.node-target == 'linux-arm64' || matrix.node-target == 'darwin-arm64' + timeout-minutes: 15 + run: | + bash rewatch-ocaml/tests/check_interactive_output.sh \ + packages/@rescript/${{ matrix.node-target }}/bin/rescript-rust.exe \ + packages/@rescript/${{ matrix.node-target }}/bin/rescript.exe + shell: bash + + - name: Run OCaml rewatch verbose output tests + timeout-minutes: 10 + run: | + bash rewatch-ocaml/tests/check_verbose_output.sh \ + packages/@rescript/${{ matrix.node-target }}/bin/rescript-rust.exe \ + packages/@rescript/${{ matrix.node-target }}/bin/rescript.exe + shell: bash + + - name: Run OCaml rewatch unit tests + timeout-minutes: 10 + run: | + opam exec -- dune build tests/rewatch_ounit_tests/rewatch_ounit_tests_main.exe + # Run the executable directly so a stuck test leaves live progress; + # dune otherwise buffers the test output until the action exits. + opam exec -- ./_build/default/tests/rewatch_ounit_tests/rewatch_ounit_tests_main.exe -runner sequential + shell: bash + + - name: Run OCaml rewatch focused integration tests + timeout-minutes: 30 + run: | + sh rewatch-ocaml/tests/run.sh packages/@rescript/${{ matrix.node-target }}/bin/rescript.exe + shell: bash + + - name: Run OCaml rewatch canonical tests + run: ./rewatch/tests/suite.sh packages/@rescript/${{ matrix.node-target }}/bin/rescript.exe + shell: bash + + - name: Run Rust rewatch reference tests on Windows + if: runner.os == 'Windows' + run: ./rewatch/tests/suite.sh packages/@rescript/${{ matrix.node-target }}/bin/rescript-rust.exe shell: bash - name: Run syntax benchmarks @@ -623,6 +676,10 @@ jobs: shell: bash working-directory: rewatch/testrepo - - name: Run rewatch integration tests + - name: Run installed default OCaml rewatch integration tests run: ./rewatch/tests/suite.sh rewatch/testrepo/node_modules/.bin/rescript shell: bash + + - name: Run installed Rust rewatch reference integration tests + run: ./rewatch/tests/suite.sh rewatch/testrepo/node_modules/.bin/rescript-rust + shell: bash diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index af2f13cbbed..733caa109e0 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -44,7 +44,7 @@ jobs: with: compiler: ${{ env.OCAML_COMPILER }} os: ${{ env.OS }} - cache-prefix: opam-coverage-v1 + cache-prefix: opam-coverage-v2 dependency-flags: --deps-only --with-test --with-dev-setup - name: Install OCaml coverage tooling diff --git a/Makefile b/Makefile index 1bc5e8cd7d6..f0fda0191d3 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,8 @@ $(YARN_INSTALL_STAMP): $(YARN_INSTALL_SOURCES) REWATCH_SOURCES = $(shell find rewatch/src -name '*.rs') rewatch/Cargo.toml rewatch/Cargo.lock rewatch/rust-toolchain.toml RESCRIPT_EXE = $(BIN_DIR)/rescript.exe +RESCRIPT_RUST_EXE := $(BIN_DIR)/rescript-rust.exe +PACKAGED_RUST_EXE := $(RESCRIPT_RUST_EXE) ifdef CI REWATCH_PROFILE := release REWATCH_CARGO_FLAGS := --release @@ -88,22 +90,22 @@ else endif REWATCH_TARGET := rewatch/target/$(REWATCH_PROFILE)/rescript$(PLATFORM_EXE_EXT) -rewatch: $(RESCRIPT_EXE) +rewatch: $(PACKAGED_RUST_EXE) -$(RESCRIPT_EXE): $(REWATCH_TARGET) +$(PACKAGED_RUST_EXE): $(REWATCH_TARGET) $(call COPY_EXE,$<,$@) $(REWATCH_TARGET): $(REWATCH_SOURCES) cargo build --manifest-path rewatch/Cargo.toml $(REWATCH_CARGO_FLAGS) clean-rewatch: - cargo clean --manifest-path rewatch/Cargo.toml && rm -rf rewatch/target && rm -f $(RESCRIPT_EXE) + cargo clean --manifest-path rewatch/Cargo.toml && rm -rf rewatch/target && rm -f $(PACKAGED_RUST_EXE) # Compiler -COMPILER_SOURCE_DIRS := compiler tests analysis tools -COMPILER_SOURCES = $(shell find $(COMPILER_SOURCE_DIRS) -type f \( -name '*.ml' -o -name '*.mli' -o -name '*.dune' -o -name dune -o -name dune-project \)) -COMPILER_BIN_NAMES := bsc rescript-editor-analysis rescript-tools +COMPILER_SOURCE_DIRS := compiler tests analysis tools rewatch-ocaml +COMPILER_SOURCES = $(shell find $(COMPILER_SOURCE_DIRS) -type f \( -name '*.ml' -o -name '*.mli' -o -name '*.c' -o -name '*.h' -o -name '*.dune' -o -name dune -o -name dune-project \)) +COMPILER_BIN_NAMES := bsc rescript-editor-analysis rescript-tools rescript COMPILER_EXES := $(addsuffix .exe,$(addprefix $(BIN_DIR)/,$(COMPILER_BIN_NAMES))) compiler: $(COMPILER_EXES) @@ -279,9 +281,13 @@ COVERAGE_TEST_ENV := BISECT_FILE=$(COVERAGE_BISECT_PREFIX) BISECT_SILENT=YES .PHONY: coverage-build coverage-build: | $(YARN_INSTALL_STAMP) dune build --instrument-with bisect_ppx - @$(foreach bin,$(COMPILER_BIN_NAMES), \ + @$(foreach bin,$(filter-out rescript,$(COMPILER_BIN_NAMES)), \ cp $(DUNE_BIN_DIR)/$(bin)$(PLATFORM_EXE_EXT) $(BIN_DIR)/$(bin).exe && \ chmod 755 $(BIN_DIR)/$(bin).exe;) +ifneq ($(OS),Windows_NT) + cp _build/default/rewatch-ocaml/rescript_ocaml.exe $(RESCRIPT_EXE) + chmod 755 $(RESCRIPT_EXE) +endif .PHONY: coverage-prepare coverage-prepare: clean-coverage coverage-build diff --git a/REWATCH_OCAML.md b/REWATCH_OCAML.md new file mode 100644 index 00000000000..0c8c8af5b99 --- /dev/null +++ b/REWATCH_OCAML.md @@ -0,0 +1,239 @@ +# Goal: Port rewatch from Rust to OCaml + +Implement an OCaml version of the ReScript build system currently in `rewatch/`, reproducing its existing behavior. Continue invoking `bsc` as an external process, including parallel subprocess execution. + +The OCaml implementation must run multiple `bsc` subprocesses concurrently, preserving Rust rewatch’s dependency-aware parallel scheduling. + +Direct in-process compiler integration and compiler-state caching are outside this goal. OCaml 5 domains are permitted if useful for implementing the build tool, but are not required: compilation parallelism comes from running independent `bsc` processes. + +Build the OCaml implementation alongside Rust rewatch, preferably in `rewatch-ocaml/`. Preserve the Rust implementation and unrelated worktree changes. Do not switch the production default as part of this task. + +## Working approach + +Follow `AGENTS.md` and read the relevant area guides. Start with: + +- `rewatch/README.md` +- `rewatch/CompilerConfigurationSpec.md` +- `rewatch/MonorepoSupport.md` +- `rewatch/Features.md` +- `rewatch/src/`, particularly configuration, package discovery, build scheduling, and watching +- `rewatch/tests/` and `rewatch/testrepo/` + +Inventory the commands, options, configuration fields, platform behavior, and tests. Treat the current Rust implementation as the behavioral reference. Record the reference commit so ongoing upstream changes do not silently change the target. + +Implement idiomatic OCaml rather than translating Rust structure mechanically. Keep configuration, module graphs, build state, subprocess management, artifact handling, and watching in cohesive modules with explicit ownership. Local mutation is fine; avoid unnecessary process-wide globals. + +Choose dependencies pragmatically. A small native C/Rust watcher component or helper process is acceptable if it provides dependable platform support. Compare existing OCaml watcher libraries, libuv bindings, and established implementations before building a backend. Respect licenses and document packaging requirements. + +This is an implementation task. Progress autonomously through the milestones, including tests and review. Milestone gates are verification checkpoints, not requests for routine user approval. + +## Required compatibility + +Cover all current rewatch responsibilities, including: + +- Commands, CLI options, configuration validation and precedence. +- Package dependencies, monorepos, namespaces, source discovery, generated and feature-gated directories. +- Compiler discovery, environment overrides, PPX and compiler argument construction. +- Parsing through `bsc`, dependency extraction, cycle detection, and dependency-ordered compilation. +- Bounded parallel subprocess scheduling. +- Incremental invalidation, interface changes, and stale artifact cleanup. +- Diagnostics, exit status, verbosity, and supported tracing behavior. +- Watch-event handling, configuration changes, error recovery, and shutdown. +- Supported-platform path, process, and filesystem behavior. + +Do not silently ignore unsupported options or configuration. Document temporary gaps precisely. + +Exact parser and Unicode semantics of Rust's `regex` crate are not required for +`--filter`. The OCaml port may expose a documented, visibly rejected subset +through a maintained native OCaml matcher. Known parser and non-ASCII matching +differences must remain documented and covered at the compatibility boundary. + +## Milestones and gates + +### 1. Working one-shot build + +Add build integration and a separately named experimental executable. Implement a complete single-package build: configuration → discovery → parsing → dependency graph → compilation → artifacts and diagnostics. + +Support multiple modules, `.res`/`.resi` pairs, dependency cycles, compilation failures, and a subsequent successful build. + +**Gate:** Selected existing fixtures produce equivalent results under Rust and OCaml rewatch. + +### 2. Configuration and workspace parity + +Complete configuration handling, commands and options, namespaces, dependency packages, monorepos, generated directories, and feature-gated sources. + +Maintain a concise compatibility matrix covering the inventoried behavior. + +**Gate:** Relevant existing fixtures pass, and every configuration field and command is accounted for. + +### 3. Parallel subprocess scheduling + +Run independent `bsc` processes concurrently with bounded parallelism. Schedule work only when prerequisites are satisfied. Handle failed prerequisites, output collection, child-process cleanup, interruption, and shutdown without deadlocks or conflicting writes. + +The compiler remains an external executable; this milestone does not require parallel OCaml compiler execution. + +**Gate:** Sequential and parallel builds agree, failure paths are tested, and clean-build performance is compared with Rust rewatch. + +### 4. Incremental builds + +Port dirty-state propagation and artifact ownership. Handle source and interface edits, additions, deletions, renames, dependency changes, configuration changes, and recovery after failed builds. + +Temporary over-invalidation is acceptable if documented; under-invalidation is not. + +**Gate:** After every step in representative edit sequences, incremental results match a clean reference build. + +### 5. Watch mode + +Integrate a watcher backend. Handle recursive watching, newly created directories, editor atomic saves, duplicate or reordered events, batching, configuration changes, and changes arriving during a build. + +Ensure coherent diagnostics, reliable error recovery, clean shutdown, and bounded resource usage. + +**Gate:** Applicable existing watch tests pass reliably. Platform support and unverified platforms are stated explicitly. + +### 6. Full compatibility and evaluation + +Run the complete applicable rewatch suite. Account for every failure and close implementation gaps without weakening tests. Compare clean builds, unchanged builds, edit latency, watch responsiveness, peak memory, and packaging requirements. + +**Gate:** Deliver a working port with a factual compatibility and performance report. Do not replace Rust rewatch or begin in-process compiler integration. + +## Testing + +Reuse existing fixtures and integration infrastructure. Parameterize the runner or add a thin alternative runner rather than duplicating the fixture tree. + +Compare: + +- Exit status and diagnostics. +- Compiler invocation arguments where relevant. +- Generated JavaScript and compiler artifacts. +- Created and removed files. +- Behavior after edit sequences and failed builds. + +Compare deterministic output exactly. Where normalization or semantic comparison is necessary, explain why and ensure it does not hide differences. + +Add focused unit tests for configuration, graph algorithms, invalidation, argument construction, and event normalization. Use end-to-end tests to establish observable behavior. + +Do not use fixed sleeps for asynchronous tests. Wait for explicit observable conditions. Run focused tests during development and broader relevant checks at milestone gates. Serialize tests that mutate shared fixtures. + +## Code quality + +Produce code that a maintainer can understand and extend: + +- Prefer idiomatic OCaml and established repository conventions. +- Keep interfaces narrow and state ownership clear. +- Avoid speculative abstractions, trivial wrapper layers, duplicated logic, and oversized catch-all modules. +- Do not add future compiler-integration machinery. +- Do not hard-code fixture-specific behavior. +- Do not swallow errors or substitute success-shaped defaults. +- Pass subprocess arguments directly rather than constructing interpolated shell commands. +- Clean up processes, file descriptors, temporary files, and watcher resources on success and failure. +- Remove dead code, abandoned experiments, stale comments, and placeholders before completing a milestone. +- Comments should explain invariants and non-obvious decisions rather than restate the code. + Start with why the code or invariant is needed, give enough context for a + reader who is not a specialist in every relevant OCaml, build-system, + compiler, or operating-system detail, and make the explanation stand on its + own. Refer to the Rust implementation only when that compatibility + relationship is itself the reason for the decision. +- Do not suppress warnings or weaken tests to make the port pass. +- Measure before introducing performance-driven complexity. +- Treat performance parity as a work-equivalence gate, not only a wall-clock + ratio. Inventory the Rust implementation's avoidance strategies (including + filesystem traversal, metadata calls, parsing, graph construction, artifact + checks, subprocess creation, and output capture), and implement applicable + missing strategies before accepting the benchmark. Use syscall or equivalent + tracing where available to detect superfluous work. +- Keep proposed optimizations that are not present in Rust in a separate, + prioritized backlog. For each proposal, record whether it addresses a measured + bottleneck or is still a hypothesis, its expected benefit, complexity and + correctness risk, Windows implications, and the benchmark plus equivalence + checks required before adoption. Do not mix speculative improvements into the + compatibility port merely to improve headline timings. + +## Review gates + +After every milestone: + +1. The implementation agent reviews the complete milestone diff, simplifies unnecessary code, checks parity against Rust, and runs formatting, compilation, and relevant tests. +2. A separate reviewer with fresh context reviews the code, corresponding Rust behavior, tests, and acceptance criteria. +3. The implementer addresses findings, explains any disagreement with evidence, and reruns relevant checks. Material fixes receive a focused follow-up review. + +Review both correctness and maintainability. Require concrete findings with affected code and consequences; avoid speculative redesigns and style churn. + +For subprocess scheduling, incremental invalidation, watch mode, and final evaluation, use two independent reviewers with complementary scopes: behavioral correctness, and design/resource/concurrency concerns. + +Do not call a milestone complete while confirmed material findings remain unresolved. + +### Final code-quality gate + +After behavior, work equivalence, and performance gates pass, perform a distinct +whole-port maintainability pass before release: + +- Split modules whose size or mixed responsibilities obstruct review; keep test + code and benchmark tooling separate from production implementation. +- Review module, file, type, function, field, and test names for clear ownership + and consistent terminology. Remove misleading Rust-derived names and unclear + abbreviations, while keeping established ReScript concepts recognizable. + Review opened modules at the same time: explicitly qualify calls when doing + so makes ownership or side effects clearer, especially for generic utility + names, but retain an open when qualification would only add repetitive noise. +- Simplify duplicated control flow and remove dead code, stale compatibility + scaffolding, abandoned experiments, and avoidable allocations without + regressing measured performance. +- Audit optional booleans and other encodings with unnamed states. Prefer a + normal variant when each state has distinct meaning so invalid combinations + are unrepresentable and compiler errors name the missing case. +- Document the unit, focused, canonical, full-repository, work-equivalence, + performance, filesystem-call, and source-size tooling so future changes can + reproduce the gates. +- Require warning-free builds, formatting, and available linters without warning + suppressions. +- Audit every process, pipe descriptor, watcher handle, lock, and staged or + temporary output across success, failure, interruption, and partial-launch + paths. +- Audit the platform boundary for hidden Unix assumptions and type-check both + selected and unselected implementations. Keep the Windows implementation in + the tree, but defer native Windows runtime validation and switching Windows + from Rust to a separate follow-up PR. +- Review dependency maintenance, licenses/notices, static packaging, and the + final npm artifact manifest. +- Remove the temporary `save-pr-cache` setup-OCaml input and its + `rewatch-ocaml` branch settings from CI and coverage once the new OPAM cache + key is available on the default branch, and in all cases before merge. +- Review test isolation and reliability, replacing fragile sleeps with observable + polling where possible and retaining tests for every intentional Rust + divergence or corrected Rust bug. +- Recheck public diagnostics, exit classes, redirected/interactive output, and + CLI discoverability. +- Record final production/test/tooling line counts and largest modules as review + signals, not optimization targets. +- Publish separate final inventories of (a) compatibility behavior retained even + though it appears odd or inconsistent, (b) documented Rust bugs or simple + inefficiencies intentionally corrected by the OCaml port, and (c) possible + post-parity performance improvements absent from Rust. Include rationale, + coverage, and a future cleanup or validation path for every entry. + +**Gate:** The whole-port review has no unresolved material correctness, +resource, portability, maintainability, documentation, or packaging finding, +and all behavior/performance gates still pass after cleanup. + +After that release-quality gate, perform the broad source-comment pass for +ownership, concurrency, platform, cleanup, and algorithmic invariants that are +not evident from the code. Do not add comments that merely paraphrase +statements. Run formatting and a build check after this comment-only pass. + +## Models + +Use **GPT-5.6 Sol at medium reasoning** for implementation and ordinary independent reviews. Use **GPT-5.6 Terra at medium reasoning** for bounded tasks with clear acceptance criteria. + +After each milestone, perform an implementation self-review and one independent review. Fix confirmed findings and rerun relevant tests. Request a second review only when substantial fixes, unresolved concerns, or particularly complex scheduling or invalidation logic justify it. Perform a final whole-port review. + +Escalate to Sol high reasoning for a specific difficult issue when medium repeatedly fails to resolve it. Use Astra only with explicit user approval. Do not automatically increase reasoning effort based on milestone number or task size. + +Evaluate model suitability after the first working build milestone using behavioral correctness, code clarity, review findings, and rework required. Keep medium as the default if those results are satisfactory. + +## Completion and reporting + +Maintain one concise progress document containing the reference commit, completed milestones, compatibility gaps, tests, measurements, review outcomes, and next actions. Avoid generating a collection of redundant planning documents. + +The goal is complete when the OCaml port reproduces current rewatch behavior, passes the applicable suite, still invokes `bsc` externally, and includes clear build/run/test instructions. Required behavior that remains unsupported means the goal is incomplete; unavailable platform verification must be disclosed. + +At milestones, report what works, what was verified, remaining gaps, and material decisions. If execution is interrupted, leave a buildable, tested checkpoint and precise continuation instructions. Resume from that checkpoint rather than treating partial progress as completion. diff --git a/biome.json b/biome.json index 94358cad64f..09994b663ab 100644 --- a/biome.json +++ b/biome.json @@ -64,6 +64,7 @@ "!**/tests/tests/**/src", "!**/tests/tools_tests/**/src", "!**/rewatch", + "!**/rewatch-ocaml", "!**/lib/es6", "!**/lib/js", "!**/lib/bs", diff --git a/cli/common/bins.js b/cli/common/bins.js index 5800a54f5ea..4d11717a1ad 100644 --- a/cli/common/bins.js +++ b/cli/common/bins.js @@ -6,7 +6,13 @@ const minimumNodeVersion = "20.11.0"; * @typedef {import("@rescript/linux-x64")} BinaryModuleExports */ -const target = `${process.platform}-${process.arch}`; +// Windows on ARM runs the published x64 toolchain through the OS emulation +// layer. Native ARM64 Node must therefore resolve the same package as x64 Node. +const binaryArch = + process.platform === "win32" && process.arch === "arm64" + ? "x64" + : process.arch; +const target = `${process.platform}-${binaryArch}`; const supportedPlatforms = [ "darwin-arm64", @@ -43,6 +49,7 @@ export const { rescript_editor_analysis_exe, rescript_tools_exe, rescript_exe, + rescript_rust_exe, }, } = mod; diff --git a/cli/common/runBuildSystem.js b/cli/common/runBuildSystem.js new file mode 100644 index 00000000000..09ce5ae8427 --- /dev/null +++ b/cli/common/runBuildSystem.js @@ -0,0 +1,72 @@ +// @ts-check + +import * as child_process from "node:child_process"; +import { runtimePath } from "./runtime.js"; + +/** @type {Record} */ +const signalToNumber = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1, SIGQUIT: 3 }; + +/** + * Run a build-system executable with the package runtime and forward terminal + * signals so watch mode can clean up before the Node launcher exits. + * + * @param {string} executable + */ +export function runBuildSystem(executable) { + const child = child_process.spawn(executable, process.argv.slice(2), { + stdio: "inherit", + env: { ...process.env, RESCRIPT_RUNTIME: runtimePath }, + }); + + let forwardedSignal = false; + /** @param {NodeJS.Signals} signal */ + const handleSignal = signal => { + if (forwardedSignal) return; + forwardedSignal = true; + // Ctrl+C is delivered to every process attached to the Windows console. + // child.kill("SIGINT") uses TerminateProcess there and can kill the build + // system before its console handler releases locks and owned descendants. + if (process.platform === "win32" && signal === "SIGINT") return; + try { + if (child.exitCode === null && child.signalCode == null) { + child.kill(signal); + } + } catch { + // Signal forwarding is best effort if the child exited concurrently. + } + }; + + process.on("SIGINT", handleSignal); + process.on("SIGTERM", handleSignal); + process.on("SIGHUP", handleSignal); + process.on("SIGQUIT", handleSignal); + + process.on("exit", () => { + if (child.exitCode === null && child.signalCode == null) { + try { + child.kill("SIGTERM"); + } catch { + // The child may already have exited. + } + } + }); + + child.on("exit", (code, signal) => { + process.removeListener("SIGINT", handleSignal); + process.removeListener("SIGTERM", handleSignal); + process.removeListener("SIGHUP", handleSignal); + process.removeListener("SIGQUIT", handleSignal); + + if (signal) { + const number = signalToNumber[signal]; + process.exit(typeof number === "number" ? 128 + number : 1); + } else { + process.exit(typeof code === "number" ? code : 0); + } + }); + + child.on("error", error => { + console.error(error?.message ?? String(error)); + process.exit(1); + }); +} diff --git a/cli/rescript-rust.js b/cli/rescript-rust.js new file mode 100755 index 00000000000..af48395a16f --- /dev/null +++ b/cli/rescript-rust.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +// @ts-check + +import { rescript_rust_exe } from "./common/bins.js"; +import { runBuildSystem } from "./common/runBuildSystem.js"; + +runBuildSystem(rescript_rust_exe); diff --git a/cli/rescript.js b/cli/rescript.js index 236e1847e82..8256b0b76c0 100755 --- a/cli/rescript.js +++ b/cli/rescript.js @@ -1,93 +1,6 @@ #!/usr/bin/env node -// @ts-check - -import * as child_process from "node:child_process"; import { rescript_exe } from "./common/bins.js"; -import { runtimePath } from "./common/runtime.js"; - -const args = process.argv.slice(2); - -// We intentionally use spawn (async) instead of execFileSync (sync) here. -// Rationale: -// - execFileSync blocks Node's event loop, so Ctrl+C (SIGINT) causes Node to -// exit immediately without giving us a chance to forward the signal to the -// child and wait for its cleanup. In watch mode, the Rust watcher prints -// "Exiting..." on SIGINT and performs cleanup; with execFileSync that output -// may appear after the shell prompt and sometimes requires an extra keypress. -// - spawn lets us install signal handlers, forward them to the child, and then -// exit the parent with the correct status only after the child has exited. -const child = child_process.spawn(rescript_exe, args, { - stdio: "inherit", - env: { ...process.env, RESCRIPT_RUNTIME: runtimePath }, -}); - -// Map POSIX signal names to conventional exit status numbers so we can -// reproduce the usual 128 + signal behavior when exiting due to a signal. -/** @type {Record} */ -const signalToNumber = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1, SIGQUIT: 3 }; - -let forwardedSignal = false; -/** - * @param {NodeJS.Signals} signal - */ -const handleSignal = signal => { - // Intercept the signal in the parent, forward it to the child, and let the - // child perform its own cleanup. This ensures ordered shutdown in watch mode. - // Guard against double-forwarding since terminals or OSes can deliver - // multiple signals (e.g., repeated Ctrl+C). - // Prevent Node from exiting immediately; forward to child first - if (forwardedSignal) return; - forwardedSignal = true; - try { - if (child.exitCode === null && child.signalCode == null) { - child.kill(signal); - } - } catch { - // best effort - } -}; - -process.on("SIGINT", handleSignal); -process.on("SIGTERM", handleSignal); -process.on("SIGHUP", handleSignal); -process.on("SIGQUIT", handleSignal); - -// Cross-platform note: -// - On Unix, Ctrl+C sends SIGINT to the process group; we also explicitly -// forward it to the child to be robust. -// - On Windows, Node maps kill('SIGINT'/'SIGTERM') to console control events; -// the Rust watcher (via the ctrlc crate) handles these and exits cleanly. - -// Ensure no orphaned process if parent exits unexpectedly -process.on("exit", () => { - if (child.exitCode === null && child.signalCode == null) { - try { - child.kill("SIGTERM"); - } catch { - // ignore - } - } -}); - -child.on("exit", (code, signal) => { - process.removeListener("SIGINT", handleSignal); - process.removeListener("SIGTERM", handleSignal); - process.removeListener("SIGHUP", handleSignal); - process.removeListener("SIGQUIT", handleSignal); - - // If the child exited due to a signal, emulate the conventional exit status - // (128 + signalNumber). Otherwise, pass through the child's numeric exit code. - if (signal) { - const n = signalToNumber[signal]; - process.exit(typeof n === "number" ? 128 + n : 1); - } else { - process.exit(typeof code === "number" ? code : 0); - } -}); +import { runBuildSystem } from "./common/runBuildSystem.js"; -// Surface spawn errors (e.g., executable not found) and exit with failure. -child.on("error", err => { - console.error(err?.message ?? String(err)); - process.exit(1); -}); +runBuildSystem(rescript_exe); diff --git a/compiler/sync/dune b/compiler/sync/dune index e11fdfacaae..c8f334b877e 100644 --- a/compiler/sync/dune +++ b/compiler/sync/dune @@ -7,10 +7,10 @@ ; cause no timestamp churn downstream. ; ; One rule per platform; %{system}/%{architecture} come from `ocamlc -config` -; (note: x64 is "amd64" there). Windows copies without stripping, matching -; the historical packaging step. The browser profile is excluded because it -; builds a playground-flavoured compiler that must never overwrite the -; native binaries. +; (note: x64 is "amd64" there). Every package receives the OCaml build system +; as rescript.exe; Cargo separately promotes the Rust reference as +; rescript-rust.exe. The browser profile is excluded because it builds a +; playground-flavoured compiler that must never overwrite the native binaries. (rule (enabled_if @@ -18,11 +18,16 @@ (<> %{profile} browser) (= %{system} macosx) (= %{architecture} arm64))) - (targets bsc.exe rescript-editor-analysis.exe rescript-tools.exe) + (targets + bsc.exe + rescript-editor-analysis.exe + rescript-tools.exe + rescript.exe) (deps ../bsc/rescript_compiler_main.exe ../../analysis/bin/main.exe - ../../tools/bin/main.exe) + ../../tools/bin/main.exe + ../../rewatch-ocaml/rescript_ocaml.exe) (mode (promote (until-clean) @@ -31,7 +36,8 @@ (progn (run strip -o bsc.exe ../bsc/rescript_compiler_main.exe) (run strip -o rescript-editor-analysis.exe ../../analysis/bin/main.exe) - (run strip -o rescript-tools.exe ../../tools/bin/main.exe)))) + (run strip -o rescript-tools.exe ../../tools/bin/main.exe) + (run strip -o rescript.exe ../../rewatch-ocaml/rescript_ocaml.exe)))) (rule (enabled_if @@ -39,11 +45,16 @@ (<> %{profile} browser) (= %{system} macosx) (= %{architecture} amd64))) - (targets bsc.exe rescript-editor-analysis.exe rescript-tools.exe) + (targets + bsc.exe + rescript-editor-analysis.exe + rescript-tools.exe + rescript.exe) (deps ../bsc/rescript_compiler_main.exe ../../analysis/bin/main.exe - ../../tools/bin/main.exe) + ../../tools/bin/main.exe + ../../rewatch-ocaml/rescript_ocaml.exe) (mode (promote (until-clean) @@ -52,7 +63,8 @@ (progn (run strip -o bsc.exe ../bsc/rescript_compiler_main.exe) (run strip -o rescript-editor-analysis.exe ../../analysis/bin/main.exe) - (run strip -o rescript-tools.exe ../../tools/bin/main.exe)))) + (run strip -o rescript-tools.exe ../../tools/bin/main.exe) + (run strip -o rescript.exe ../../rewatch-ocaml/rescript_ocaml.exe)))) (rule (enabled_if @@ -60,11 +72,16 @@ (<> %{profile} browser) (= %{system} linux) (= %{architecture} arm64))) - (targets bsc.exe rescript-editor-analysis.exe rescript-tools.exe) + (targets + bsc.exe + rescript-editor-analysis.exe + rescript-tools.exe + rescript.exe) (deps ../bsc/rescript_compiler_main.exe ../../analysis/bin/main.exe - ../../tools/bin/main.exe) + ../../tools/bin/main.exe + ../../rewatch-ocaml/rescript_ocaml.exe) (mode (promote (until-clean) @@ -73,7 +90,8 @@ (progn (run strip -o bsc.exe ../bsc/rescript_compiler_main.exe) (run strip -o rescript-editor-analysis.exe ../../analysis/bin/main.exe) - (run strip -o rescript-tools.exe ../../tools/bin/main.exe)))) + (run strip -o rescript-tools.exe ../../tools/bin/main.exe) + (run strip -o rescript.exe ../../rewatch-ocaml/rescript_ocaml.exe)))) (rule (enabled_if @@ -81,11 +99,16 @@ (<> %{profile} browser) (= %{system} linux) (= %{architecture} amd64))) - (targets bsc.exe rescript-editor-analysis.exe rescript-tools.exe) + (targets + bsc.exe + rescript-editor-analysis.exe + rescript-tools.exe + rescript.exe) (deps ../bsc/rescript_compiler_main.exe ../../analysis/bin/main.exe - ../../tools/bin/main.exe) + ../../tools/bin/main.exe + ../../rewatch-ocaml/rescript_ocaml.exe) (mode (promote (until-clean) @@ -94,7 +117,8 @@ (progn (run strip -o bsc.exe ../bsc/rescript_compiler_main.exe) (run strip -o rescript-editor-analysis.exe ../../analysis/bin/main.exe) - (run strip -o rescript-tools.exe ../../tools/bin/main.exe)))) + (run strip -o rescript-tools.exe ../../tools/bin/main.exe) + (run strip -o rescript.exe ../../rewatch-ocaml/rescript_ocaml.exe)))) (rule (enabled_if @@ -102,11 +126,16 @@ (<> %{profile} browser) (= %{system} mingw64) (= %{architecture} amd64))) - (targets bsc.exe rescript-editor-analysis.exe rescript-tools.exe) + (targets + bsc.exe + rescript-editor-analysis.exe + rescript-tools.exe + rescript.exe) (deps ../bsc/rescript_compiler_main.exe ../../analysis/bin/main.exe - ../../tools/bin/main.exe) + ../../tools/bin/main.exe + ../../rewatch-ocaml/rescript_ocaml.exe) (mode (promote (until-clean) @@ -115,4 +144,5 @@ (progn (copy ../bsc/rescript_compiler_main.exe bsc.exe) (copy ../../analysis/bin/main.exe rescript-editor-analysis.exe) - (copy ../../tools/bin/main.exe rescript-tools.exe)))) + (copy ../../tools/bin/main.exe rescript-tools.exe) + (copy ../../rewatch-ocaml/rescript_ocaml.exe rescript.exe)))) diff --git a/dune b/dune index 91a5df6eca9..c8ac51fbe74 100644 --- a/dune +++ b/dune @@ -1 +1 @@ -(dirs compiler tests analysis tools) +(dirs compiler tests analysis tools rewatch-ocaml) diff --git a/dune-project b/dune-project index 8e34a29f118..8ab17cd367a 100644 --- a/dune-project +++ b/dune-project @@ -32,6 +32,14 @@ (and :with-test (= 0.29.0))) (yojson (= 3.0.0)) + (spawn + (= v0.17.0)) + (cmdliner + (= 2.1.1)) + (luv + (= 0.5.14)) + (re + (= 1.14.0)) (ounit2 (and :with-test (= 2.2.7))) (odoc :with-doc) diff --git a/package.json b/package.json index 50c42cffed2..3f676a7e7d2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "bin": { "bsc": "cli/bsc.js", "rescript": "cli/rescript.js", + "rescript-rust": "cli/rescript-rust.js", "rescript-tools": "cli/rescript-tools.js" }, "scripts": { diff --git a/packages/@rescript/darwin-arm64/RE_LICENSE.md b/packages/@rescript/darwin-arm64/RE_LICENSE.md new file mode 100644 index 00000000000..f79ddd2b654 --- /dev/null +++ b/packages/@rescript/darwin-arm64/RE_LICENSE.md @@ -0,0 +1,521 @@ +This Software is distributed under the terms of the GNU Lesser +General Public License version 2.1 (included below), or (at your +option) any later version. + +As a special exception to the GNU Library General Public License, you +may link, statically or dynamically, a "work that uses the Library" +with a publicly distributed version of the Library to produce an +executable file containing portions of the Library, and distribute +that executable file under terms of your choice, without any of the +additional requirements listed in clause 6 of the GNU Library General +Public License. By "a publicly distributed version of the Library", +we mean either the unmodified Library, or a modified version of the +Library that is distributed under the conditions defined in clause 3 +of the GNU Library General Public License. This exception does not +however invalidate any other reasons why the executable file might be +covered by the GNU Library General Public License. + +---------------------------------------------------------------------- + + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/packages/@rescript/darwin-arm64/THIRD_PARTY_NOTICES_REWATCH.md b/packages/@rescript/darwin-arm64/THIRD_PARTY_NOTICES_REWATCH.md new file mode 100644 index 00000000000..9a2c787e47d --- /dev/null +++ b/packages/@rescript/darwin-arm64/THIRD_PARTY_NOTICES_REWATCH.md @@ -0,0 +1,85 @@ +# Third-party notices for the OCaml ReScript build system + +The `rescript.exe` OCaml build-system binary includes the following +third-party software. These notices supplement the package's declared license. + +## Cmdliner + +Copyright (c) 2011 The cmdliner programmers + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## Yojson + +Copyright (c) 2010-2012, Martin Jambon +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +## Re + +Copyright (C) 2001 Jerome Vouillon + +Re is distributed under the GNU Lesser General Public License version 2.1 or, +at your option, any later version, with the OCaml linking exception. The full +license and exception are included in `RE_LICENSE.md`. + +## Spawn, Luv, libuv, ctypes, and integers + +- Spawn: Copyright (c) 2016-2018 Jane Street Group, LLC +- Luv: Copyright (c) 2018-2023 Anton Bachin +- libuv: Copyright (c) 2015-present libuv project contributors +- ctypes: Copyright (c) 2013 Jeremy Yallop +- integers: Copyright (c) 2013-2016 Jeremy Yallop + +Each project listed in this section is distributed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/@rescript/darwin-arm64/bin.d.ts b/packages/@rescript/darwin-arm64/bin.d.ts index f6fa8daaca5..d6e39948452 100644 --- a/packages/@rescript/darwin-arm64/bin.d.ts +++ b/packages/@rescript/darwin-arm64/bin.d.ts @@ -7,4 +7,5 @@ export type BinaryPaths = { rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; + rescript_rust_exe: string; }; diff --git a/packages/@rescript/darwin-arm64/bin.js b/packages/@rescript/darwin-arm64/bin.js index aff7c9c9d93..290e8d7a241 100644 --- a/packages/@rescript/darwin-arm64/bin.js +++ b/packages/@rescript/darwin-arm64/bin.js @@ -12,4 +12,5 @@ export const binPaths = { "rescript-editor-analysis.exe", ), rescript_exe: path.join(binDir, "rescript.exe"), + rescript_rust_exe: path.join(binDir, "rescript-rust.exe"), }; diff --git a/packages/@rescript/darwin-arm64/package.json b/packages/@rescript/darwin-arm64/package.json index e5c0bf036fb..eb1c693319a 100644 --- a/packages/@rescript/darwin-arm64/package.json +++ b/packages/@rescript/darwin-arm64/package.json @@ -35,7 +35,8 @@ "./bin/bsc.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", - "./bin/rescript.exe" + "./bin/rescript.exe", + "./bin/rescript-rust.exe" ] }, "engines": { @@ -50,6 +51,8 @@ "files": [ "bin.js", "bin.d.ts", + "THIRD_PARTY_NOTICES_REWATCH.md", + "RE_LICENSE.md", "bin/" ], "exports": "./bin.js", diff --git a/packages/@rescript/darwin-x64/RE_LICENSE.md b/packages/@rescript/darwin-x64/RE_LICENSE.md new file mode 100644 index 00000000000..f79ddd2b654 --- /dev/null +++ b/packages/@rescript/darwin-x64/RE_LICENSE.md @@ -0,0 +1,521 @@ +This Software is distributed under the terms of the GNU Lesser +General Public License version 2.1 (included below), or (at your +option) any later version. + +As a special exception to the GNU Library General Public License, you +may link, statically or dynamically, a "work that uses the Library" +with a publicly distributed version of the Library to produce an +executable file containing portions of the Library, and distribute +that executable file under terms of your choice, without any of the +additional requirements listed in clause 6 of the GNU Library General +Public License. By "a publicly distributed version of the Library", +we mean either the unmodified Library, or a modified version of the +Library that is distributed under the conditions defined in clause 3 +of the GNU Library General Public License. This exception does not +however invalidate any other reasons why the executable file might be +covered by the GNU Library General Public License. + +---------------------------------------------------------------------- + + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/packages/@rescript/darwin-x64/THIRD_PARTY_NOTICES_REWATCH.md b/packages/@rescript/darwin-x64/THIRD_PARTY_NOTICES_REWATCH.md new file mode 100644 index 00000000000..9a2c787e47d --- /dev/null +++ b/packages/@rescript/darwin-x64/THIRD_PARTY_NOTICES_REWATCH.md @@ -0,0 +1,85 @@ +# Third-party notices for the OCaml ReScript build system + +The `rescript.exe` OCaml build-system binary includes the following +third-party software. These notices supplement the package's declared license. + +## Cmdliner + +Copyright (c) 2011 The cmdliner programmers + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## Yojson + +Copyright (c) 2010-2012, Martin Jambon +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +## Re + +Copyright (C) 2001 Jerome Vouillon + +Re is distributed under the GNU Lesser General Public License version 2.1 or, +at your option, any later version, with the OCaml linking exception. The full +license and exception are included in `RE_LICENSE.md`. + +## Spawn, Luv, libuv, ctypes, and integers + +- Spawn: Copyright (c) 2016-2018 Jane Street Group, LLC +- Luv: Copyright (c) 2018-2023 Anton Bachin +- libuv: Copyright (c) 2015-present libuv project contributors +- ctypes: Copyright (c) 2013 Jeremy Yallop +- integers: Copyright (c) 2013-2016 Jeremy Yallop + +Each project listed in this section is distributed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/@rescript/darwin-x64/bin.d.ts b/packages/@rescript/darwin-x64/bin.d.ts index f6fa8daaca5..d6e39948452 100644 --- a/packages/@rescript/darwin-x64/bin.d.ts +++ b/packages/@rescript/darwin-x64/bin.d.ts @@ -7,4 +7,5 @@ export type BinaryPaths = { rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; + rescript_rust_exe: string; }; diff --git a/packages/@rescript/darwin-x64/bin.js b/packages/@rescript/darwin-x64/bin.js index aff7c9c9d93..290e8d7a241 100644 --- a/packages/@rescript/darwin-x64/bin.js +++ b/packages/@rescript/darwin-x64/bin.js @@ -12,4 +12,5 @@ export const binPaths = { "rescript-editor-analysis.exe", ), rescript_exe: path.join(binDir, "rescript.exe"), + rescript_rust_exe: path.join(binDir, "rescript-rust.exe"), }; diff --git a/packages/@rescript/darwin-x64/package.json b/packages/@rescript/darwin-x64/package.json index 3d7e7e7e4f7..6d03cca3d13 100644 --- a/packages/@rescript/darwin-x64/package.json +++ b/packages/@rescript/darwin-x64/package.json @@ -35,7 +35,8 @@ "./bin/bsc.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", - "./bin/rescript.exe" + "./bin/rescript.exe", + "./bin/rescript-rust.exe" ] }, "engines": { @@ -50,6 +51,8 @@ "files": [ "bin.js", "bin.d.ts", + "THIRD_PARTY_NOTICES_REWATCH.md", + "RE_LICENSE.md", "bin/" ], "exports": "./bin.js", diff --git a/packages/@rescript/linux-arm64/RE_LICENSE.md b/packages/@rescript/linux-arm64/RE_LICENSE.md new file mode 100644 index 00000000000..f79ddd2b654 --- /dev/null +++ b/packages/@rescript/linux-arm64/RE_LICENSE.md @@ -0,0 +1,521 @@ +This Software is distributed under the terms of the GNU Lesser +General Public License version 2.1 (included below), or (at your +option) any later version. + +As a special exception to the GNU Library General Public License, you +may link, statically or dynamically, a "work that uses the Library" +with a publicly distributed version of the Library to produce an +executable file containing portions of the Library, and distribute +that executable file under terms of your choice, without any of the +additional requirements listed in clause 6 of the GNU Library General +Public License. By "a publicly distributed version of the Library", +we mean either the unmodified Library, or a modified version of the +Library that is distributed under the conditions defined in clause 3 +of the GNU Library General Public License. This exception does not +however invalidate any other reasons why the executable file might be +covered by the GNU Library General Public License. + +---------------------------------------------------------------------- + + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/packages/@rescript/linux-arm64/THIRD_PARTY_NOTICES_REWATCH.md b/packages/@rescript/linux-arm64/THIRD_PARTY_NOTICES_REWATCH.md new file mode 100644 index 00000000000..9a2c787e47d --- /dev/null +++ b/packages/@rescript/linux-arm64/THIRD_PARTY_NOTICES_REWATCH.md @@ -0,0 +1,85 @@ +# Third-party notices for the OCaml ReScript build system + +The `rescript.exe` OCaml build-system binary includes the following +third-party software. These notices supplement the package's declared license. + +## Cmdliner + +Copyright (c) 2011 The cmdliner programmers + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## Yojson + +Copyright (c) 2010-2012, Martin Jambon +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +## Re + +Copyright (C) 2001 Jerome Vouillon + +Re is distributed under the GNU Lesser General Public License version 2.1 or, +at your option, any later version, with the OCaml linking exception. The full +license and exception are included in `RE_LICENSE.md`. + +## Spawn, Luv, libuv, ctypes, and integers + +- Spawn: Copyright (c) 2016-2018 Jane Street Group, LLC +- Luv: Copyright (c) 2018-2023 Anton Bachin +- libuv: Copyright (c) 2015-present libuv project contributors +- ctypes: Copyright (c) 2013 Jeremy Yallop +- integers: Copyright (c) 2013-2016 Jeremy Yallop + +Each project listed in this section is distributed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/@rescript/linux-arm64/bin.d.ts b/packages/@rescript/linux-arm64/bin.d.ts index f6fa8daaca5..d6e39948452 100644 --- a/packages/@rescript/linux-arm64/bin.d.ts +++ b/packages/@rescript/linux-arm64/bin.d.ts @@ -7,4 +7,5 @@ export type BinaryPaths = { rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; + rescript_rust_exe: string; }; diff --git a/packages/@rescript/linux-arm64/bin.js b/packages/@rescript/linux-arm64/bin.js index aff7c9c9d93..290e8d7a241 100644 --- a/packages/@rescript/linux-arm64/bin.js +++ b/packages/@rescript/linux-arm64/bin.js @@ -12,4 +12,5 @@ export const binPaths = { "rescript-editor-analysis.exe", ), rescript_exe: path.join(binDir, "rescript.exe"), + rescript_rust_exe: path.join(binDir, "rescript-rust.exe"), }; diff --git a/packages/@rescript/linux-arm64/package.json b/packages/@rescript/linux-arm64/package.json index 710ce52c049..415d22d9d93 100644 --- a/packages/@rescript/linux-arm64/package.json +++ b/packages/@rescript/linux-arm64/package.json @@ -35,7 +35,8 @@ "./bin/bsc.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", - "./bin/rescript.exe" + "./bin/rescript.exe", + "./bin/rescript-rust.exe" ] }, "engines": { @@ -50,6 +51,8 @@ "files": [ "bin.js", "bin.d.ts", + "THIRD_PARTY_NOTICES_REWATCH.md", + "RE_LICENSE.md", "bin/" ], "exports": "./bin.js", diff --git a/packages/@rescript/linux-x64/RE_LICENSE.md b/packages/@rescript/linux-x64/RE_LICENSE.md new file mode 100644 index 00000000000..f79ddd2b654 --- /dev/null +++ b/packages/@rescript/linux-x64/RE_LICENSE.md @@ -0,0 +1,521 @@ +This Software is distributed under the terms of the GNU Lesser +General Public License version 2.1 (included below), or (at your +option) any later version. + +As a special exception to the GNU Library General Public License, you +may link, statically or dynamically, a "work that uses the Library" +with a publicly distributed version of the Library to produce an +executable file containing portions of the Library, and distribute +that executable file under terms of your choice, without any of the +additional requirements listed in clause 6 of the GNU Library General +Public License. By "a publicly distributed version of the Library", +we mean either the unmodified Library, or a modified version of the +Library that is distributed under the conditions defined in clause 3 +of the GNU Library General Public License. This exception does not +however invalidate any other reasons why the executable file might be +covered by the GNU Library General Public License. + +---------------------------------------------------------------------- + + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/packages/@rescript/linux-x64/THIRD_PARTY_NOTICES_REWATCH.md b/packages/@rescript/linux-x64/THIRD_PARTY_NOTICES_REWATCH.md new file mode 100644 index 00000000000..9a2c787e47d --- /dev/null +++ b/packages/@rescript/linux-x64/THIRD_PARTY_NOTICES_REWATCH.md @@ -0,0 +1,85 @@ +# Third-party notices for the OCaml ReScript build system + +The `rescript.exe` OCaml build-system binary includes the following +third-party software. These notices supplement the package's declared license. + +## Cmdliner + +Copyright (c) 2011 The cmdliner programmers + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## Yojson + +Copyright (c) 2010-2012, Martin Jambon +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +## Re + +Copyright (C) 2001 Jerome Vouillon + +Re is distributed under the GNU Lesser General Public License version 2.1 or, +at your option, any later version, with the OCaml linking exception. The full +license and exception are included in `RE_LICENSE.md`. + +## Spawn, Luv, libuv, ctypes, and integers + +- Spawn: Copyright (c) 2016-2018 Jane Street Group, LLC +- Luv: Copyright (c) 2018-2023 Anton Bachin +- libuv: Copyright (c) 2015-present libuv project contributors +- ctypes: Copyright (c) 2013 Jeremy Yallop +- integers: Copyright (c) 2013-2016 Jeremy Yallop + +Each project listed in this section is distributed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/@rescript/linux-x64/bin.d.ts b/packages/@rescript/linux-x64/bin.d.ts index f6fa8daaca5..d6e39948452 100644 --- a/packages/@rescript/linux-x64/bin.d.ts +++ b/packages/@rescript/linux-x64/bin.d.ts @@ -7,4 +7,5 @@ export type BinaryPaths = { rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; + rescript_rust_exe: string; }; diff --git a/packages/@rescript/linux-x64/bin.js b/packages/@rescript/linux-x64/bin.js index aff7c9c9d93..290e8d7a241 100644 --- a/packages/@rescript/linux-x64/bin.js +++ b/packages/@rescript/linux-x64/bin.js @@ -12,4 +12,5 @@ export const binPaths = { "rescript-editor-analysis.exe", ), rescript_exe: path.join(binDir, "rescript.exe"), + rescript_rust_exe: path.join(binDir, "rescript-rust.exe"), }; diff --git a/packages/@rescript/linux-x64/package.json b/packages/@rescript/linux-x64/package.json index 7ad6910a236..b1b00446385 100644 --- a/packages/@rescript/linux-x64/package.json +++ b/packages/@rescript/linux-x64/package.json @@ -35,7 +35,8 @@ "./bin/bsc.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", - "./bin/rescript.exe" + "./bin/rescript.exe", + "./bin/rescript-rust.exe" ] }, "engines": { @@ -50,6 +51,8 @@ "files": [ "bin.js", "bin.d.ts", + "THIRD_PARTY_NOTICES_REWATCH.md", + "RE_LICENSE.md", "bin/" ], "exports": "./bin.js", diff --git a/packages/@rescript/win32-x64/RE_LICENSE.md b/packages/@rescript/win32-x64/RE_LICENSE.md new file mode 100644 index 00000000000..f79ddd2b654 --- /dev/null +++ b/packages/@rescript/win32-x64/RE_LICENSE.md @@ -0,0 +1,521 @@ +This Software is distributed under the terms of the GNU Lesser +General Public License version 2.1 (included below), or (at your +option) any later version. + +As a special exception to the GNU Library General Public License, you +may link, statically or dynamically, a "work that uses the Library" +with a publicly distributed version of the Library to produce an +executable file containing portions of the Library, and distribute +that executable file under terms of your choice, without any of the +additional requirements listed in clause 6 of the GNU Library General +Public License. By "a publicly distributed version of the Library", +we mean either the unmodified Library, or a modified version of the +Library that is distributed under the conditions defined in clause 3 +of the GNU Library General Public License. This exception does not +however invalidate any other reasons why the executable file might be +covered by the GNU Library General Public License. + +---------------------------------------------------------------------- + + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/packages/@rescript/win32-x64/THIRD_PARTY_NOTICES_REWATCH.md b/packages/@rescript/win32-x64/THIRD_PARTY_NOTICES_REWATCH.md new file mode 100644 index 00000000000..9a2c787e47d --- /dev/null +++ b/packages/@rescript/win32-x64/THIRD_PARTY_NOTICES_REWATCH.md @@ -0,0 +1,85 @@ +# Third-party notices for the OCaml ReScript build system + +The `rescript.exe` OCaml build-system binary includes the following +third-party software. These notices supplement the package's declared license. + +## Cmdliner + +Copyright (c) 2011 The cmdliner programmers + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## Yojson + +Copyright (c) 2010-2012, Martin Jambon +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +## Re + +Copyright (C) 2001 Jerome Vouillon + +Re is distributed under the GNU Lesser General Public License version 2.1 or, +at your option, any later version, with the OCaml linking exception. The full +license and exception are included in `RE_LICENSE.md`. + +## Spawn, Luv, libuv, ctypes, and integers + +- Spawn: Copyright (c) 2016-2018 Jane Street Group, LLC +- Luv: Copyright (c) 2018-2023 Anton Bachin +- libuv: Copyright (c) 2015-present libuv project contributors +- ctypes: Copyright (c) 2013 Jeremy Yallop +- integers: Copyright (c) 2013-2016 Jeremy Yallop + +Each project listed in this section is distributed under the MIT License: + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/@rescript/win32-x64/bin.d.ts b/packages/@rescript/win32-x64/bin.d.ts index f6fa8daaca5..d6e39948452 100644 --- a/packages/@rescript/win32-x64/bin.d.ts +++ b/packages/@rescript/win32-x64/bin.d.ts @@ -7,4 +7,5 @@ export type BinaryPaths = { rescript_tools_exe: string; rescript_editor_analysis_exe: string; rescript_exe: string; + rescript_rust_exe: string; }; diff --git a/packages/@rescript/win32-x64/bin.js b/packages/@rescript/win32-x64/bin.js index aff7c9c9d93..290e8d7a241 100644 --- a/packages/@rescript/win32-x64/bin.js +++ b/packages/@rescript/win32-x64/bin.js @@ -12,4 +12,5 @@ export const binPaths = { "rescript-editor-analysis.exe", ), rescript_exe: path.join(binDir, "rescript.exe"), + rescript_rust_exe: path.join(binDir, "rescript-rust.exe"), }; diff --git a/packages/@rescript/win32-x64/package.json b/packages/@rescript/win32-x64/package.json index b17bd1462ee..dbf24b3ed99 100644 --- a/packages/@rescript/win32-x64/package.json +++ b/packages/@rescript/win32-x64/package.json @@ -35,7 +35,8 @@ "./bin/bsc.exe", "./bin/rescript-editor-analysis.exe", "./bin/rescript-tools.exe", - "./bin/rescript.exe" + "./bin/rescript.exe", + "./bin/rescript-rust.exe" ] }, "engines": { @@ -45,11 +46,14 @@ "win32" ], "cpu": [ - "x64" + "x64", + "arm64" ], "files": [ "bin.js", "bin.d.ts", + "THIRD_PARTY_NOTICES_REWATCH.md", + "RE_LICENSE.md", "bin/" ], "exports": "./bin.js", diff --git a/packages/artifacts.json b/packages/artifacts.json index 698863cfec4..68db4ca4a52 100644 --- a/packages/artifacts.json +++ b/packages/artifacts.json @@ -11,7 +11,9 @@ "cli/common/args.js", "cli/common/bins.js", "cli/common/minisocket.js", + "cli/common/runBuildSystem.js", "cli/common/runtime.js", + "cli/rescript-rust.js", "cli/rescript-tools.js", "cli/rescript.js", "docs/docson/build-schema.json", @@ -837,5 +839,70 @@ "src/Belt_internalSetBuckets.resi", "src/Belt_internalSetInt.res", "src/Belt_internalSetString.res" + ], + "@rescript/darwin-arm64": [ + "RE_LICENSE.md", + "THIRD_PARTY_NOTICES_REWATCH.md", + "bin.d.ts", + "bin.js", + "bin/.gitkeep", + "bin/bsc.exe", + "bin/rescript-editor-analysis.exe", + "bin/rescript-rust.exe", + "bin/rescript-tools.exe", + "bin/rescript.exe", + "package.json" + ], + "@rescript/darwin-x64": [ + "RE_LICENSE.md", + "THIRD_PARTY_NOTICES_REWATCH.md", + "bin.d.ts", + "bin.js", + "bin/.gitkeep", + "bin/bsc.exe", + "bin/rescript-editor-analysis.exe", + "bin/rescript-rust.exe", + "bin/rescript-tools.exe", + "bin/rescript.exe", + "package.json" + ], + "@rescript/linux-arm64": [ + "RE_LICENSE.md", + "THIRD_PARTY_NOTICES_REWATCH.md", + "bin.d.ts", + "bin.js", + "bin/.gitkeep", + "bin/bsc.exe", + "bin/rescript-editor-analysis.exe", + "bin/rescript-rust.exe", + "bin/rescript-tools.exe", + "bin/rescript.exe", + "package.json" + ], + "@rescript/linux-x64": [ + "RE_LICENSE.md", + "THIRD_PARTY_NOTICES_REWATCH.md", + "bin.d.ts", + "bin.js", + "bin/.gitkeep", + "bin/bsc.exe", + "bin/rescript-editor-analysis.exe", + "bin/rescript-rust.exe", + "bin/rescript-tools.exe", + "bin/rescript.exe", + "package.json" + ], + "@rescript/win32-x64": [ + "RE_LICENSE.md", + "THIRD_PARTY_NOTICES_REWATCH.md", + "bin.d.ts", + "bin.js", + "bin/.gitkeep", + "bin/bsc.exe", + "bin/rescript-editor-analysis.exe", + "bin/rescript-rust.exe", + "bin/rescript-tools.exe", + "bin/rescript.exe", + "package.json" ] } \ No newline at end of file diff --git a/rescript.opam b/rescript.opam index ddd418b554e..9af3064583b 100644 --- a/rescript.opam +++ b/rescript.opam @@ -16,6 +16,10 @@ depends: [ "wtf8" "ocamlformat" {with-test & = "0.29.0"} "yojson" {= "3.0.0"} + "spawn" {= "v0.17.0"} + "cmdliner" {= "2.1.1"} + "luv" {= "0.5.14"} + "re" {= "1.14.0"} "ounit2" {with-test & = "2.2.7"} "odoc" {with-doc} "ocaml-lsp-server" {with-dev-setup & >= "1.23.0"} diff --git a/rewatch-ocaml/PARITY_CHECKLIST.md b/rewatch-ocaml/PARITY_CHECKLIST.md new file mode 100644 index 00000000000..8beca399a20 --- /dev/null +++ b/rewatch-ocaml/PARITY_CHECKLIST.md @@ -0,0 +1,202 @@ +# Rewatch parity checklist + +This checklist complements the shared integration suite. A passing suite proves +the scenarios it exercises; it does not by itself prove that every Rust guard, +diagnostic, or interactive output path has an OCaml equivalent. + +## Architecture mapping gate + +The final port must provide a clear mapping from each material Rust +responsibility, state value, algorithm, and lifecycle transition to its OCaml +owner. In particular, package discovery, build and compile-asset state, +dependency extraction and invalidation, parsing, compilation, cleanup, and the +watcher lifecycle must be traceable across the two implementations. The OCaml +implementation should perform equivalent work in the corresponding phase and +consume already-computed state where Rust does, rather than repeatedly using +the filesystem as an implicit database. + +This is not a requirement to reproduce Rust file sizes, function boundaries, +or control-flow syntax mechanically. Idiomatic OCaml boundaries are preferred. +A material deviation needs a concrete correctness, portability, +maintainability, or simple-efficiency reason, and must be documented with its +behavioral evidence and Windows implications. Deliberate Rust bug fixes remain +permitted under the same rule. + +Rust's existing OpenTelemetry spans may be used to identify phase ownership, +duration, and overlap while constructing this mapping. OTEL export remains an +intentional non-goal for the OCaml executable, and instrumented timings are +diagnostic rather than benchmark results. Filesystem-call parity is measured +separately with the retained syscall-audit tooling. + +| Rust owner | Responsibility | OCaml owner | Mapping status | +| --- | --- | --- | --- | +| `config.rs` | Public configuration model, JSON decoding, validation, and derived arguments | `config_types.ml`, `config_decode.ml`, and `config.ml` | The public `Config` facade is preserved; types/error identity, decoding, and top-level loading/runtime queries have explicit owners | +| `build.rs` | Command build lifecycle and phase orchestration | `build.ml`, `build_preparation.ml`, `package_build.ml`, `package_parse.ml`, `package_compilation.ml`, `build_report.ml`, and `build_attempt.ml` | Stable initialization, per-package parsing/compilation, presentation, and finalization have separate owners; `build.ml` retains the transaction and aggregate dispatch | +| `project_context.rs` | Workspace classification, package lookup, locality, and path presentation | `project_context.ml` | Explicit owner now selects the ReScript-level workspace root, resolves the contextual package/current/workspace `node_modules` candidates plus standalone ancestor hoists, classifies canonical dependencies within the invocation's local scope, and presents project-relative paths; graph traversal retains the command-wide resolved-package cache | +| `build/build_types.rs` | Packages, modules, dirty flags, dependency edges, and compiled-asset timestamps | `package_plan.ml`, `module_graph.ml`, `build_session.ml`, `build_attempt.ml`, `build_state.ml`, and `source.ml` | Immutable package inputs, graph identities, retained session state, per-attempt lifecycle, mutable module state, and source discovery have distinct lifetime owners | +| `build/packages.rs` | Package resolution and source/module discovery | `package_graph.ml` over `project_context.ml`, `package_diagnostics.ml`, `package_plan.ml`, `config.ml`, and `source.ml` | The graph owner unifies dependency resolution, duplicate selection, feature requests, dependency permissions, local/dev policy, source discovery, compile configuration, and prepared-package ordering; source discovery retains mtimes, the full cleanup leaf inventory, and GenType directories; Unix traversal deduplicates directories by metadata identity while Windows uses canonical paths | +| `build/read_compile_state.rs` | One compile-asset inventory for cleanup and freshness | `compile_assets.ml`, `build_freshness.ml`, `build_state.ml`, and `build_preparation.ml` | The shared inventory supplies CMI/CMT timestamps and published-AST source locations/mtimes; preparation performs cleanup and seeds build state from it, the freshness owner consumes those snapshots, and, like Rust, metadata is read only for AST/IAST/CMI/CMT state | +| `build/compiler_info.rs` | Compiler/config fingerprints and package invalidation | `compiler_info.ml` | Compiler, runtime, package config, source-map arguments, effective root package-output specs, and artifact build owner invalidate affected packages; previous output specs drive precise stale-output removal, including installed source dependencies, while independently built package outputs remain owned by that package rather than an unrelated consumer | +| `build/logs.rs` | Compiler-log lifecycle and ANSI-free persisted diagnostics | `compiler_log.ml` | Explicit owner initializes, appends, finalizes, strips terminal control sequences, and publishes each package log; warning selection remains with compilation as in Rust's `build/compile.rs` | +| `build/clean.rs` | Stale and explicit artifact cleanup | `clean.ml` over `build_artifacts.ml` and `file_util.ml` | General filesystem operations have a narrow reusable owner; recursive command traversal and artifact ownership live in `clean.ml` and `build_artifacts.ml`; stale cleanup consumes shared compile-asset/source inventories and directly calculated working paths; consumer clean preserves independently owned dependency artifacts instead of mutating a published package, while consumer-built source dependencies remain recursive; a whole-tree `lib/bs` scan is retained only as a lazy malformed/legacy-artifact fallback | +| `build/deps.rs` | Dependency extraction, edges, and invalidation | `module_graph.ml` over `graph.ml` and `build_state.ml` | AST dependency extraction, package visibility, global edges, state construction, and cycle handling have a cohesive owner; generic graph algorithms and mutable module state retain their reusable owners | +| `build/parse.rs` | Parser arguments/jobs and parse-state transitions | `compiler_args.ml`, `compiler_process.ml`, `build_preparation.ml`, `package_parse.ml`, and `package_build.ml` | Phase-specific flags and source-dependent PPX policy have an explicit owner; external parser jobs and AST dependency decoding live with compiler processes; global preparation owns the preliminary parse, while package parsing owns per-package publication and retained parse-state transitions | +| `build/compile.rs` | Compiler arguments, dirty propagation, scheduling, publication | `compiler_args.ml`, `package_compilation.ml`, `compiler_process.ml`, `compiler_scheduler.ml`, `build_state.ml`, `process.ml`, and `process_child.ml` | Package compilation owns dirty-set and job construction; child ownership is separate from the two scheduling policies; the main domain owns deterministic dependency admission while bounded domains own subprocess and publication lifetimes | +| `watcher.rs` | Watch handles, batching, rebuild lifecycle, and recovery | `watcher.ml` over `watch_scope.ml`, `watch_snapshot.ml`, and `native_watcher.ml` | Scope discovery derives local-package roots and handles, snapshots preserve content baselines, and the lifecycle owner reconciles native wakeups, refreshes handles after configuration changes, preserves edits arriving during builds, falls back to polling, and owns signal/lock/handle cleanup; `native_watcher.ml` remains the narrow libuv boundary | +| `lock.rs` | Build/watch ownership and stale-process handling | `build_lock.ml`, with process operations behind `platform.mli` | Build and watch locks share one owner for PID validation, stale-lock takeover, ownership checks, and release; the hard-link primitive still requires native Windows validation and may move behind the platform interface if necessary | +| `telemetry.rs` | Optional OTLP export | No OCaml owner | Intentional project-level omission; Rust traces remain diagnostic tooling | + +## Validation inventory gate + +Before the port can replace Rust rewatch, inventory every user-reachable +validation and sanity check in the pinned Rust implementation. Search at least +these owners and record each check below (splitting rows as needed): + +- `cli.rs` and `project_context.rs`: argument shape, command context, project + discovery, missing folders, and configuration-file selection. +- `config.rs`: JSON shape, deprecated/unsupported fields, feature maps, + package outputs, source directories, dependencies, warnings, JSX, GenType, + and post-build configuration. +- `helpers.rs`, `lock.rs`, and `watcher.rs`: compiler/runtime discovery, path + and executable checks, lock ownership, watcher lifecycle, and event inputs. +- `build.rs` and `build/*.rs`: package resolution, dependency permissions, + duplicate modules, cycles, namespaces, compiler subprocess failures, output + ownership, and cleanup safety. +- `format.rs`: input modes, extensions, formatter lookup/failure, and check + status. + +For every Rust check, the final inventory must name its Rust source location, +OCaml source location, and focused or canonical test. A missing check is an open +gap. A deliberate difference needs a rationale and regression test recorded in +this checklist or the implementation README; similar wording alone is not proof +of equivalent behavior. + +| Validation area | Current evidence | Status | +| --- | --- | --- | +| Missing/non-project folder and config discovery | A differential command gate covers missing, config-less, and malformed build folders plus no-project compiler inputs; project-context tests distinguish listed parent dependencies from unrelated packages under a `package.json` workspace | Matched for `ProjectContext::new`, parent-config selection, and root/config discovery; command tests retain the selected configuration path while parser- and OS-native detail is allowed to differ | +| Configuration schema and aliases | A 297-case differential gate covers every typed configuration family and exact arguments for shared accepted behavior; twelve documented Rust/OCaml divergences are explicit expectations rather than omitted cases | Matched for typed schema and argument projection, with deliberate stricter map-key validation and an ignored internal `path` key; Serde and Yojson parse locations/wording remain library-native | +| Package/dependency graph | Canonical compile/feature cases and graph unit tests; the differential command gate covers missing, config-less, malformed, duplicate-path, metadata-name-mismatched, malformed-package-metadata, and omitted dependency-source cases across the commands that construct the graph; parse/compile/cleanup guards are inventoried below | Matched for graph construction, scheduling, reachable cleanup states, and stable package/path diagnostic context; implicit format now validates and scans the complete graph before selecting its local files, and exact warning checks retain first-path duplicate selection, omitted-`sources`, and missing-source-folder behavior | +| Compiler/runtime/executable discovery | Packaged-layout test removes `RESCRIPT_BSC_EXE` and uses sibling `bsc.exe`; focused runtime test removes `RESCRIPT_RUNTIME` and resolves `@rescript/runtime`; differential command cases cover missing explicit compiler/runtime paths and an unavailable runtime package; platform path tests cover Windows verbatim drive and UNC paths; the native Windows package gate compares Dune-promoted executables byte for byte and dry-packs both rewatch implementations | Matched for discovery and environment precedence on Unix and native Windows; OCaml reports a stale explicit compiler normally instead of Rust's panic and rejects a stale explicit runtime before spawning compiler work instead of Rust's later `Pervasives` failures | +| Locks and watcher lifecycle | Canonical lock/watch cases, focused atomic/stale-lock tests, a slow-child lock-removal integration case, scheduler cancellation unit coverage, and differential malformed build/watch lock cases that preserve unknown ownership; native Windows runs cover Job assignment, descendant-held-pipe cancellation, native-PID lock ownership/contention, lock-driven cancellation, and watcher execution/recovery | Matched for acquisition, active-owner refusal/waiting, valid stale-owner takeover, malformed-owner refusal, workspace scope, owned cleanup, and lock-driven cancellation of active child process trees. Unix retains process-group ownership; Windows creates each child suspended, assigns it to a retained Job Object before resuming it, and rejects launch if atomic ownership cannot be established. Normal completion only releases the job, while cancellation terminates the tree even after the direct wait handle is reaped. Signal handlers record atomic cross-domain requests; the scheduler owner turns them into fatal outcomes at a poll point, avoiding asynchronous unwinding in worker launch/publication. Windows serializes temporary process-wide handler replacement. MSYS cannot deliver a normal console-control event to the native child, so that harness uses lock removal to exercise the same cancellation owner; Unix retains direct signal-delivery coverage. | +| Output ownership and cleanup | Canonical clean/suffix/removal cases, focused stale-artifact tests, and `clean_tests.ml` coverage that distinguishes configured outputs from neighboring unowned files in both the root and an installed dependency and cleans both colliding outputs without constructing a valid module graph; the fresh-build gate compares the complete Rust/OCaml file-name set and hashes every byte-stable artifact class; OUnit covers concurrent directory creators, strict close-error propagation, and same-directory atomic metadata replacement; the focused suite runs the lifecycle natively on Windows | Matched for explicit clean ownership, including invalid duplicate-module graphs; fresh outputs/control files; stale compiler/output cleanup; concurrent directory creators; and rejection of existing non-directories on Unix and native Windows. Obsolete `.rewatch-pending` and `.rewatch-backup` handling was removed because neither supported implementation creates those files. Generated JSON and namespace maps publish complete replacements, while formatted user sources retain their inode and therefore symlink, hard-link, ownership, ACL, and extended-attribute behavior | +| CLI and format input validation | Dedicated Cmdliner tests mirror Rust CLI cases, including required/surplus `compiler-args` paths; compiler-args tests cover extension, dependency selection, and missing-package behavior; focused format failures cover stdin labels, check summaries, and contextual write errors; differential cases exactly compare explicit missing-file, directory, and unsupported-extension failures, retain the stale-compiler Rust panic fix, semantically compare missing/malformed/directory-valued implicit project configs and invalid stdin, and exercise permission-denied write-back where supported; canonical format/compiler-args cases cover success | Matched or documented across CLI shape, formatter/compiler discovery, explicit-file failures, implicit project discovery, stdin, check status, and write-back; OS errors, temporary names, and malformed JSON retain platform/library-native detail after common command/path context | + +No row becomes complete until the Rust source inventory has been performed, +not merely because the current tests pass. + +### Configuration inventory + +Symbols below are stable source locations; line numbers are intentionally +omitted because the Rust and OCaml files are still changing. + +| Behavior | Rust location | OCaml location | Evidence | Status | +| --- | --- | --- | --- | --- | +| File read, JSON root, required `name`, internal `path`, legacy filename, optional JSON `null`, and duplicate keys | `config.rs`: `Config::new`, `Config::new_from_json_string`, `Config::set_path`; Serde struct/`Option` fields | `config.ml`: `load`, `load_root`; `config_decode.ml`: optional members and duplicate rejection | Unit tests cover missing/directory paths without raw exceptions and verify that the filename read remains authoritative; focused missing-project test; differential audits cover root/name/path shapes, 41 `null` positions, and representative duplicate keys; command cases retain missing, malformed, parent, and directory-valued config paths | Deliberate difference: OCaml treats the internal JSON `path` key as unknown and ignores any value instead of type-checking a value that is immediately overwritten; Serde/Yojson parse locations and OS error detail remain library/platform-native after shared path context | +| Source forms, `dir`, `subdirs`, `type`, feature inheritance | `config.rs`: `Source`, `PackageSource`; `build/packages.rs`: `get_source_dirs`, `make_package` | `config_decode.ml`: structured source decoding; `config.ml`: `source_is_dev`; `config_types.ml`: source model and `sources_defined`; `package_diagnostics.ml`: `report_missing_sources` | A retained 36-case Rust/OCaml differential gate covers accepted and rejected outer, qualified, nested, nullable, unknown, and duplicate shapes and compares arguments for accepted cases; unit and canonical tests cover flattening and inheritance; the command gate exactly compares the omitted-`sources` dependency warning for build, clean, and format | Matched for the complete schema, field-presence warning, and flattening inventory; an explicit empty list remains distinct from an omitted field | +| Package module, suffix/location defaults and duplicate outputs | `config.rs`: `PackageSpec`, `validate_package_specs_value` | `config_decode.ml`: package-spec decoding; `config.ml`: duplicate-output validation and suffix helpers | 28 differential schema/argument cases plus unit and canonical suffix tests | Matched for the complete schema and output-conflict inventory | +| Dependency forms, aliases, feature maps and cycles | `config.rs`: `Dependency`, `resolve_active_features`, `compute_active_features`; package traversal | `config_decode.ml`: dependency and alias decoding; `source.ml`: `resolve_active_features`; `package_graph.ml` and `format.ml`: consumer-request aggregation | The differential gate adds 42 dependency, feature-map, alias, and `allowed-dependents` shapes; Rust/OCaml unit and canonical tests cover feature resolution, cycles, permissions, and traversal; command cases distinguish an unrestricted irrelevant cycle from a specifically requested cycle during build and format | Feature algorithms match: all-features selection does not traverse implication edges, while restricted requests are unioned and transitively validated. Deliberate difference: OCaml rejects duplicate feature-map keys rather than silently keeping one value. Resolution failures are covered by the package-discovery row below | +| Compiler, warning, and PPX flags | `config.rs`: `Warnings`, `flatten_flags`, `flatten_ppx_flags`, `get_warning_args`; `build/parse.rs`: `filter_ppx_flags`; `build/compile.rs`: `compiler_args` | `config_decode.ml` and `config.ml`: field/top-level flag decoding; `compiler_args.ml`: PPX filtering and phase-ordered flags | 37 differential cases cover valid and invalid shapes plus exact shared argument projection; explicit divergence cases retain whitespace normalization and the empty-PPX panic fix; exact unit argument-order/filter tests and canonical builds cover execution | Matched, with documented safety fixes | +| Namespace and namespace entry | `config.rs`: `NamespaceConfig`, `get_namespace`, `get_namespace_entry`; namespace argument helpers | `config_decode.ml`: namespace normalization; `config.ml`: top-level namespace/entry validation; `compiler_args.ml`: `namespace_args`; `build_preparation.ml`: visible namespace validation | 12 differential cases cover boolean/string normalization, scoped names, entries, nulls, and invalid kinds; canonical namespace builds cover artifacts; focused coverage rejects two directly visible packages that publish the same compiler namespace | Matched, with documented rejection of an entry when namespace is disabled; deliberate safety fix rejects ambiguous visible namespace artifacts before compilation | +| JSX and source maps | `config.rs`: `JsxSpecs`, `SourceMapConfig`, argument getters | `config.ml`: JSX and `sourceMap` branches in `load` | 53 differential schema/argument cases cover all fields, modes, nulls, JSON kinds, unknowns, and duplicate keys; all rejected cases retain the owning `jsx` or `sourceMap` context; unit and canonical build tests cover execution | Deliberate difference: OCaml rejects duplicate keys in the interpreted `sourceMap` object instead of inheriting raw JSON map last-value behavior; schema, arguments, and contextual rejection otherwise match | +| GenType schema, arguments, and project-root paths | `config.rs`: `GenTypeConfig`, `GenTypeShims`, `get_gentype_args`; `build/packages.rs`: `collect_gentype_source_dirs`; `build/parse.rs`: parse working directory | `config_decode.ml`: GenType decoding/arguments; `source.ml`: `discovery.gentype_dirs`; `package_graph.ml`: compile-config projection; `config.ml` and `compiler_process.ml`: canonical root and job paths | 53 differential cases cover every field, enum, JSON kind, nullable option, duplicate typed field, shim representation, sorting, package fallback, sources, and dependencies; unit tests cover recursive directory discovery, feature/dev-source selection, and canonical config roots; canonical tests cover execution on Unix and native Windows | Deliberate difference: OCaml rejects duplicate shim sources and debug keys rather than silently keeping the last value. Config loading canonicalizes the package root once; parse and compile paths derive from that root, and `-bs-project-root` receives the identical representation. | +| Post-build command | `config.rs`: `JsPostBuild`; `build/compile.rs` execution | `config.ml`: `js_post_build`; `compiler_process.ml`: post-build execution | 10 differential schema cases plus canonical and focused execution tests on Unix and native Windows | Matched for schema and execution, including Windows `cmd.exe` construction, success, nonzero status, retry, and streamed output | +| Deprecated, unsupported, and unknown fields | `config.rs`: all five Serde aliases, `get_unknown_fields`, `get_unsupported_fields` | `config_decode.ml`: aliases and unknown-field inventory; `config.ml`: top-level diagnostics | Unit/focused tests cover `bs-dependencies`, `bs-dev-dependencies`, `bsc-flags`, `cjs`, and `es6`, Rust's nested warning boundary, and ignored unsupported payloads | Alias and diagnostic behavior match except for the internal JSON `path` key, which OCaml deliberately treats as unknown; opaque `editor`, `reanalyze`, and unknown payloads remain outside recursive validation | + +### CLI, project context, and format inventory + +| Behavior | Rust location | OCaml location | Evidence | Status | +| --- | --- | --- | --- | --- | +| Implicit `build`, global flag placement, `--`, known commands, help, and version | `cli.rs`: `parse_with_default_from`, `should_default_to_build`, `build_default_args`; Clap command declaration | `cli.ml`: `normalize_argv`; Cmdliner command group | `cli_tests.ml` covers implicit/explicit routing, leading/trailing globals, short and long help/version, clusters, and `--`; the command gate records subcommand-version behavior as an explicit difference | Implicit build and global option placement match; Cmdliner provides consistent help/version precedence and native help layout | +| Build/watch/clean option ownership and values | `cli.rs`: `BuildArgs`, `WatchArgs`, `Command`; feature and regex value parsers | `cli.ml`: `build_term`, `clean_term`, feature and filter converters | `cli_tests.ml` covers command-only rejection, the `--no-timing` flag, production mode, feature trimming/emptiness, invalid regex, watch clear-screen, retained clean verbosity, and mutually exclusive verbose/quiet modes; the command gate records the simpler OCaml flag and display-option semantics as explicit differences | Deliberate difference: `--no-timing` is a non-consuming flag, help always takes precedence over version, and version is accepted after a subcommand | +| Source filter matching | `build/packages.rs`: `matches_filter`, `read_folders` | `source_filter.ml`: typed validation/compiled matcher; `source.ml`: `discover_with_inventory`; `cli.ml`: filter conversion | Differential builds prove that a directory-only regex excludes a nested source while basename expressions include it, including a Rust-style non-capturing alternation with `\d`; focused source/CLI tests retain basename boundaries, alternation, groups, shorthand classes, repetition, known Re-only forms, ranges, class semantics, and invalid input | Basename and positive-match semantics plus common shared syntax are matched through maintained pure-OCaml `re`. Some valid Rust syntax remains visibly unsupported, including Unicode properties and inline modes, Python-style named groups, possessive quantifiers, and class-set algebra. Known Re-only quoting, comment, anchor, control/octal escape, range, nested/collating class, and divergent in-class escape forms are rejected before compilation to avoid silently different selection. Because matching is byte-oriented, shared patterns involving non-ASCII basenames can differ, including dot, hexadecimal escapes, shorthand classes, and literal classes. Exact Rust-regex semantics are explicitly not required by project decision; the documented subset keeps a linear-time native-Windows matcher without another native dependency. | +| Watch source selection | `build/packages.rs`: feature and positive `matches_filter` discovery; `watcher.rs`: event filtering | `source.ml` and `package_graph.ml`: active discovery; `watcher.ml`: feature-aware native registrations and scoped content snapshots | Differential watch cases edit included, filter-excluded, and feature-disabled sources and inspect generated output and hook counts; a delayed compiler gate activates a source directory, creates a file after discovery, and requires exactly one queued follow-up build; an external source-symlink target is atomically replaced, removed, and recreated | Deliberate Rust bug fix: OCaml retains the CLI's positive regex meaning across initial discovery and every rebuild instead of negating it only for watch events. Feature-disabled and filter-excluded edits do not perform a no-op rebuild. Snapshots read only package control files and active configured source trees rather than recursively walking unrelated package-root contents. Target-parent watches keep regular source symlinks observable outside those trees. | +| Watch state lifetime and dependency edits | `watcher.rs`: retained `BuildState`; `build.rs`: `build_incremental`; `build/deps.rs`: dependency extraction and reverse-edge updates | `native_watcher.ml`: identity-bearing handles and event paths/kinds; `watcher.ml`: pre-build content baselines and structural reconciliation; `build.ml`: retained source index and incremental preparation; `build_preparation.ml` and `build_state.ml`: resolved and reverse edges | Focused long-lived watchers cover dependency-edge replacement, cycles and recovery, atomic replacement during active compilation, directory-handle replacement, polling fallback, and the same native Windows event lifecycle; unit coverage verifies obsolete reverse-edge removal and duplicate prevention; retained performance and filesystem gates verify exact compiler work and resource stability | Existing-file edits retain initialized package, compiler, artifact, cleanup, source-index, and module state and parse only affected source paths. Precise content events capture their baseline before building; edits arriving during compilation remain distinguishable. Structural/ambiguous events reconstruct state and refresh handles whose filesystem identity changed. Changed dependency headers replace graph edges and rerun cycle detection in memory. Deliberate low-risk Rust inefficiency fix: indexed source lookup and obsolete reverse-edge removal avoid Rust's full module scan and accumulated reverse edges. | +| Format input mode and explicit files | `cli.rs`: `Command::Format`, `FileExtension`, Clap `format_input_mode`; `format.rs`: `format_files` | `cli.ml`: `format_term`; `format.ml`: `format_files_with_bsc` | `cli_tests.ml` covers `.res`/`.resi`, invalid stdin extensions, stdin/file conflicts, and stdin/check conflicts in either argument order; differential cases exactly compare missing-file, directory, and unsupported-extension diagnostics | Matched, including errors delegated to the formatter for explicit operands | +| `compiler-args` positional and filesystem input | `cli.rs`: `Command::CompilerArgs`; `build.rs`: `get_compiler_args`; `helpers.rs`: `read_file`; `build/compile.rs`: dependency arguments | `cli.ml`: `compiler_args_term`; `compiler_args_command.ml`: project/source resolution and JSON projection; `compiler_args.ml`: shared argument policy | `cli_tests.ml` covers missing and surplus paths; the differential command gate covers valid, non-ReScript, missing, and no-project sources; `compiler_args_tests.ml` covers dev/regular dependencies and context | Matched where Rust validates, with documented extension validation and three non-panicking OCaml fixes | +| After-build hook execution | `main.rs`: successful-build hook dispatch; `cmd.rs`: `run` | `after_build.ml`: command execution; `build.ml`: post-success dispatch | Canonical/focused integration covers a successful hook; three differential command cases cover an empty command, a missing program, and a program exiting 7 with captured stderr | Deliberate safety fixes: OCaml reports empty and unlaunchable hooks normally rather than panicking, and makes a nonzero hook fail the command instead of discarding its status; all commands are still split on whitespace and launched outside the build lock like Rust. Windows inherits interactive stdin while retaining Job Object ownership. Unix terminal stdin remains closed because Spawn cannot both give the child a new, cancellable process group and atomically foreground it; redirected stdin is inherited. Full Unix interactive-hook stdin needs a tested PTY relay rather than risking `SIGTTIN` or weakening descendant cancellation. | +| Per-output JS post-build hook | `build/compile.rs`: `execute_post_build_command` and `compile_file` | `compiler_process.ml`: `run_post_build`; `platform.mli` command construction | Focused integration checks the generated-file argument for a successful hook; the differential command gate makes the shell command exit 7 and requires both diagnostics to identify the generated JavaScript path; both run natively on Windows | Matched for invocation timing, working directory, output argument, failure status, and path-bearing diagnostics, including native `cmd.exe` execution | +| Missing folder, missing config, and parent-config discovery failures | `lock.rs`: `get_lock`; `project_context.rs`: `ProjectContext::new`; `build/packages.rs`: `read_config` | `build.ml`: `project_root`; `build_lock.ml`: lock acquisition; `project_context.ml`: `workspace_lock_root`; `config.ml`: `load_root` | The differential command gate covers nonexistent, config-less, malformed, directory-config, and malformed-parent project paths; the focused runner exactly checks missing-folder wording; configuration tests cover direct file-read failures | Matched for project/config discovery outcomes and selected-path context; JSON-parser and OS-error tails remain implementation-native | +| Monorepo root/package classification | `project_context.rs`: `read_local_packages`, `is_config_listed_in_workspace`, `monorepo_or_single_project`, `get_scoped_local_packages`; `build/packages.rs`: `is_local_dependency` | `project_context.ml`: workspace classification, invocation-scoped locality, and lock root; shared by package traversal, preparation, clean, format, and watch | `project_context_tests.ml` covers listed regular/dev packages, root-versus-child locality, and an unlisted package beneath a workspace; focused integration builds a listed package whose sibling has an unresolved development dependency and inspects its source-directory metadata; canonical monorepo builds cover root-level symlinked traversal | Matched: workspace-root commands own linked workspace packages, while a command run directly from a listed package resolves siblings without including their development graph, cleanup policy, formatting scope, source scans, or watcher roots | +| Dependency package resolution | `helpers.rs`: `try_package_path`; `build/packages.rs`: `read_dependency`, `read_dependencies` | `project_context.ml`: contextual candidates; `package_resolution.ml`: shared selection, locality, config, and duplicate policy; build, clean, format, and watch consumers | The differential command gate covers missing paths, existing packages without config, malformed dependency config for build and clean, watch startup, and duplicate selection; focused coverage models local packages through `node_modules` links and requires an unlinked `packages/dep` to remain unresolved and untouched by clean | Matched candidate priority, first-package selection, locality, and standalone-only ancestor traversal. Watch retains a recoverable unresolved/config-error boundary; canonicalization, OS, and JSON-parser diagnostic tails remain implementation-native | +| Dependency permission enforcement | `build/packages.rs`: `get_unallowed_dependents`, `validate_packages_dependencies` | `package_graph.ml`: active-edge validation during discovery | Focused OUnit coverage rejects traversed regular and development dependencies; one differential case makes an installed package declare a dormant dev edge to another root dependency, while another gives a root two actively denied regular dependencies and inspects both implementations' complete output | Matched for every traversed regular/local-dev edge; deliberate Rust bug fixes: dormant installed dev edges are ignored, every denied edge is reported instead of only the first per dependency class, details remain on stderr, and guidance names `allowed-dependents` in `rescript.json` rather than obsolete identifiers | +| Duplicate dependency path selection | `build/packages.rs`: `read_dependencies` registered-dependency branch | `package_graph.ml`: command-wide resolved-package cache | The differential command gate places one dependency both at the root and below another package, requires both builds to succeed, and requires the duplicate warning from each implementation | Matched: the first path selected for a requested dependency name is retained throughout the graph; later paths warn and reuse it rather than inserting duplicate modules | +| Package metadata name | `build/packages.rs`: `read_package_name`, `make_package` | `package_metadata.ml`: `package_name`; `package_diagnostics.ml`: `package_identity` | Differential cases compare the root mismatch warning exactly, reject malformed `package.json`, and retain the dependency mismatch Rust panic as an explicit port fix; unit tests cover last-key and non-string name behavior | Matched validation and warning behavior; OCaml uses the `package.json` identity consistently and rejects a dependency requested through a conflicting name instead of reaching Rust's later panic | +| Source module/interface identity | `build/packages.rs`: `parse_packages` implementation/interface branches | `source.ml`: `discover`, `duplicate_error`, `interface_mismatch_error` | Canonical duplicate-module and orphan-interface snapshots; the differential command gate covers a basename-case mismatch; `source_tests.ml` also covers duplicate implementations and cross-directory mismatches | Matched: implementation and interface paths must agree exactly before `.res`/`.resi`; module-name collisions remain deterministic errors and orphan interfaces are skipped with a diagnostic | +| Namespace map membership | `helpers.rs`: `is_non_exotic_module_name`; `build/packages.rs`: namespace `depending_modules`/`deps` construction | `source.ml`: `is_non_exotic_module_name`; `compiler_process.ml`: `namespace_task` | A differential namespace build compares the complete generated `.mlmap` with ordinary and punctuated source names; `source_tests.ml` covers ordinary, punctuated, and empty predicate inputs | Matched: only ASCII module identifiers beginning with an uppercase letter enter the namespace map; exotic source files may still compile but are not exported through the namespace | +| Development source locality | `build/packages.rs`: `get_source_files`, `extend_with_children` (`package.is_local_dep && !prod`) | `package_traversal.ml`: `source_discovery_prod`; `package_graph.ml`: build discovery; `clean.ml`: clean traversal; `format.ml`: format discovery | The differential command gate builds through an installed dependency containing a deliberately invalid dev-only source; focused unit coverage retains all local/production combinations; canonical dev-dependency and production builds exercise local packages | Matched: installed dependencies never contribute `type: "dev"` source folders, while local packages contribute them outside `--prod` | +| Missing or non-directory source folders | `build/packages.rs`: `get_source_files` | `source.ml`: `scan_dir`; `package_diagnostics.ml`: missing-source reporting; `package_graph.ml` and `format.ml`: complete graph scans | Exact differential command cases cover an active missing folder in an installed dependency during build and implicit format plus a configured regular file; canonical watch recovery covers a missing local folder that is later created | Matched: unavailable active source directories are diagnosed with folder/package/root context but remain non-fatal; excluded dev/feature folders are not scanned | +| Parse execution and graph invariants | `build/parse.rs`: `generate_asts`, `generate_ast`; `build/deps.rs`: `get_dep_modules` | `compiler_process.ml`: parse jobs and AST dependencies; `build_preparation.ml`: preliminary global parse and graph construction; `package_build.ml`: per-package parse publication and transitions | Canonical syntax-error, warning-persistence, rename, and deletion cases plus the exact compiler-work gate cover normal failures and state transitions; an isolated differential syntax-error build exactly compares normalized stdout/stderr and phase classification; differential compiler wrappers remove discovered sources before later parse work and a successfully generated AST before dependency extraction; focused namespace coverage combines an ordinary dependency, an own-namespace-qualified reference, and a reverse dependent; package/module/namespace lookups are graph-construction invariants in both implementations | Matched for compiler outcomes and internal state, including ignoring the AST's imprecise own-namespace marker instead of expanding it into false edges; OCaml reports both disappearing-source and missing-AST races normally while Rust panics | +| Compile execution and dependency scheduling | `build/compile.rs`: scheduler, `dependency_cycle`, `compiler_args`, `compile_file`, dirty propagation | `build_preparation.ml`: global graph; `package_build.ml`: dirty-set and job preparation; `build.ml`: aggregate dispatch; `compiler_process.ml`: jobs/publication; `compiler_scheduler.ml`: scheduled compilation; `build_state.ml`; `graph.ml`: shortest-cycle selection | Canonical cycle, missing-module, interface, warning, namespace, feature, and incremental-watch cases; exact clean/unchanged/edit work manifests; fresh-tree artifact equivalence; focused `-bs-no-bin-annot` build; graph coverage requires the shortest of two disjoint cycles | Matched for user-reachable compiler and scheduler outcomes, including optional CMT/CMTI debug artifacts; equal shortest cycles and rotations are deliberately lexical rather than hash-order-dependent; package/module/interface unwraps are invariants established by the graph and scheduled job shape | +| Previous-state and stale-output cleanup | `build/read_compile_state.rs`; `build/clean.rs`: `cleanup_previous_build`, `cleanup_after_build` | `compile_assets.ml`; `build_artifacts.ml`: planned `cleanup_stale`; `source.ml`: separate ownership/presence inventories; `clean.ml`; `build.ml` finalization | Canonical rename/deletion/clean/suffix/feature cases and focused malformed-AST fallback, stale output families, directory-symlink freshness, deleted-JS repair, compiler-info invalidation, and duplicate-module clean tests | Matched for reachable artifact states. Classification precedes mutation and removes public JavaScript/maps plus working mirrors as one family. Explicit clean derives owned outputs from raw discovered implementation paths, while the non-owning presence view follows configured directory symlinks without authorizing recursive deletion | +| Editor cache-bust marker | `build.rs`: `write_build_ninja`; full rebuild call sites in `build` and `watcher.rs` | `build.ml`: `write_build_ninja`, success/failure finalization | Isolated complete control-file manifests differed only by this marker before the fix; focused success and compiler-failure builds require it, and the differential config-recovery watch case requires it after rebuilding | Matched for normal builds and structural watch recovery; the snapshot watcher conservatively rewrites it after every post-initial rebuild because it does not expose Rust's event-kind classification | +| Compiler artifact publication I/O failures | `build/compile.rs`: post-compile `fs::copy(...).expect(...)` calls | `build_artifacts.ml`: `copy_existing_file`; `rescript_ocaml.ml`: top-level I/O errors | Fresh-tree manifests prove normal publication equivalence; a differential compiler wrapper deletes the source after successful compilation; focused tests cover deleted-output repair and failed-watch recovery | Deliberate safety fix: OCaml emits a path-bearing normal error, while Rust panics its worker and leaves the scheduler waiting indefinitely; the bounded differential gate retains both outcomes | +| Watch reconfiguration failures | `watcher.rs`: full-rebuild `initialize_build(...).expect(...)` | `build.ml`: `watch` rebuild error boundary; `watcher.ml`: recoverable control/candidate paths | Differential lifecycle cases introduce invalid root and dependency JSON and an unresolved dependency, verify the OCaml watcher remains alive, then repair configuration or create the missing installed package and wait for rebuilt output | Deliberate safety fix: intermediate configuration and package-resolution errors are recoverable in OCaml watch mode instead of panicking or terminating the watcher; unresolved candidates are watched only within the workspace | +| Formatter compiler execution and errors | `format.rs`: `format_stdin`, `format_files` | `format.ml`: `formatted`, `format_stdin`, `format_files_with_bsc`, `format_files` | Focused integration runs successful and invalid stdin formatting on Unix and native Windows; `format_tests.ml` protects stable stdin/file error labels, requires two independent formatter subprocesses to overlap, and verifies that writes follow symlinks and preserve hard-link identity | Matched for bounded parallel execution, stop-on-error scheduling, subprocess failure classification, user-file identity, and native Windows execution | +| Format check result | `format.rs`: `format_files` | `format.ml`: `format_files`, `format_check_summary` | Focused integration checks path, singular summary, error, and failure status; unit tests cover singular/plural messages | Matched | +| Implicit format project scope and graph validation | `format.rs`: `get_files_in_scope`; `ProjectContext::get_scoped_local_packages`; `packages::make` | `format.ml`: `files_in_scope`, `discover_package_graph`, `package_sources`; `project_context.ml` and `package_graph.ml`: shared resolution and locality policy | Four canonical format tests cover the current fixture, a single file, stdin, and formatting from a workspace package; focused integration proves that implicit format requires a config in the current directory rather than searching parents; `format_tests.ml` proves installed `node_modules` dependencies are excluded from formatting; the differential command gate covers missing, config-less, malformed, duplicate-path, omitted-source, requested feature-cycle, installed missing-source, and local dependency feature-selection cases | Matched: one complete applicable package scan supplies both graph diagnostics and the files to format; the current package uses all features, dependencies use the union of consumer feature requests, direct symlink-local regular/dev dependencies are included only at a monorepo root, a listed child formats only itself, and transitive or installed dependencies are validated but not formatted | + +## Rust unit-test coverage gate + +[`tests/check_rust_test_coverage.sh`](tests/check_rust_test_coverage.sh) +discovers every `#[test]` and `#[tokio::test]` below `rewatch/src` and compares +that inventory with [`tests/rust_test_coverage.tsv`](tests/rust_test_coverage.tsv). +Each Rust test must map to focused OCaml coverage, the shared canonical suite, +an intentional architectural difference, an explicit project omission, or a +known gap. New Rust tests and stale mapping rows fail the ordinary check. + +Run the stricter final gate with: + +```bash +rewatch-ocaml/tests/check_rust_test_coverage.sh --require-complete +``` + +That mode also fails while any scenario is `unreviewed` or `gap`. The inventory +contains 136 Rust tests, all reviewed, with no remaining entries in either +category. They map to focused OCaml tests, the shared suite, accepted +architectural differences, or the explicit telemetry omission. A mapping is +evidence only after its cited OCaml/shared test has been inspected; grouping by +similar wording alone is not proof of equivalent behavior. Passing this unit +inventory does not replace the broader validation-source and interactive-output +gates in this document. + +## Canonical integration-test coverage gate + +CI runs the shared [`rewatch/tests/suite.sh`](../rewatch/tests/suite.sh) against +the packaged OCaml executable. Therefore its scenarios are exercised by the +port rather than copied into a second suite that could drift. The +[`tests/check_canonical_test_coverage.sh`](tests/check_canonical_test_coverage.sh) +guard inventories every test script below `rewatch/tests`, and fails if +`suite.sh` omits one, references a stale path, or references a test more than +once. It currently finds 48 canonical integration tests, all referenced +exactly once. + +This establishes shared integration-test inclusion, not exhaustive parity on +its own. Rust source paths without a test remain the responsibility of the +validation inventory above, and output modes not exercised by the shell suite +remain the responsibility of the output gate below. + +## Output parity gate + +Output is tested in two modes because Rust deliberately changes behavior based +on whether stdout and stderr are terminals. + +| Mode | Required comparison | Current status | +| --- | --- | --- | +| Redirected/plain output | Success summaries, warnings, errors, ordering, exit status, and absence of terminal control sequences unless color is explicitly forced; Cmdliner help may use its native man-page headings and layout | Matched for ordinary output: differential cases exactly compare normalized clean-build, compile-error, parse-error, successful-warning, and combined deprecated/unsupported/unknown-config stdout/stderr at default level, the first four cases at quiet level, plus default and quiet `clean`; the combined diagnostic case is also byte-compared with `CLICOLOR_FORCE=1`. A quiet redirected watcher performs initial and incremental builds without output. Format and compiler-args have focused/canonical checks. An order-insensitive differential gate matches semantic Rust `-v` project/package/parse/compile events and `-vv` dirty/completed scheduler-universe events. | +| Interactive build | TTY detection, parsing/compilation progress, spinner lifecycle, timing, colors, symbols/emojis, quiet/verbose behavior, and cleanup on interruption | Matched; a retained Linux PTY gate exactly compares normalized cleanup/parse/compile completion lines, step counts, timing, phase emojis, and final status, observes live parsing/compilation spinner frames and module totals, and verifies that `-q` suppresses them. Its namespaced fixture also retains the distinction between one actually compiled source module and a two-entry scheduler universe containing the namespace marker; warning state and positive verbosity are covered separately | +| Interactive clean | Graph-first validation, compiler-asset and generated-output phase order, package progress, timing, colors, and quiet behavior | Matched; `clean.ml` first constructs and validates a dependency-first plan, then runs compiler-asset and generated-output removal as distinct phases. The PTY gate compares both implementations' package line and five normalized `[1/2]`/`[2/2]` progress frames exactly, while redirected default/quiet behavior remains covered by the command gate. | +| Interactive watch | Initial-build and rebuild progress, clear-screen behavior, persistent warnings, recovery errors, symbols/emojis, and orderly shutdown | Matched; a retained PTY gate exactly compares Rust/OCaml initial three-step and incremental two-step phase lines, counts, symbols, and final status after normalizing timing. It also covers live spinner frames, incremental/full clear-screen headers, the post-failure watching footer, parse-error recovery, warning persistence, positive verbosity, and orderly shutdown. | +| Accessibility/terminal fallback | Stable meaningful text when color or richer glyphs are unavailable | Redirected output uses stable text without driver ANSI/glyph decoration and is covered by canonical snapshots; neither implementation probes terminal glyph support, so no richer fallback contract exists to port | + +Positive diagnostic verbosity is covered by a focused differential gate. Rust +and OCaml `-v` emit the same semantic project-context, package-discovery, +per-module parse, and per-module interface/implementation compile events; `-vv` +also matches dirty and completed scheduler-universe events. The comparison is +order-insensitive because Rust's Rayon completion order is not contractual. +The native Windows pseudo-terminal run validates the presentation behavior as +well as the platform backend. + +Interactive checks should run both implementations under a pseudo-terminal and +capture normalized frames/events rather than snapshotting spinner timing byte +for byte. Plain-output snapshots remain exact where paths and ANSI sequences +can be normalized deterministically. diff --git a/rewatch-ocaml/README.md b/rewatch-ocaml/README.md new file mode 100644 index 00000000000..00ee935cfcf --- /dev/null +++ b/rewatch-ocaml/README.md @@ -0,0 +1,223 @@ +# Experimental OCaml rewatch + +This directory contains the OCaml port of the ReScript build system. Linux, +macOS, and Windows packages use it experimentally as `rescript`; the Rust +implementation remains available as `rescript-rust` on every platform. + +## Status + +The cross-platform implementation, parity, performance, and release-quality +gates are complete. The post-rebase Linux clean-build gate measured a 4.740 s Rust +median and a 5.020 s OCaml median (1.059x), with identical compiler work, generated-file +sets, and byte-stable artifacts. A separate 1,425-module macOS project measured +approximately 9.6 s for Rust and 11.5 s for OCaml (about 1.20x). Absolute timing +is host-specific; the reproducible method and complete resource/work results +are in [`bench/README.md`](bench/README.md). + +The final uninterrupted `make test-all` run passed all compiler, runtime, +build, GenType, analysis, tools, and canonical rewatch tests. The port has 42 +OUnit2 cases, and all 48 shared integration tests run against the packaged OCaml +executable. All 136 Rust unit tests were reviewed: 128 map to focused or shared +tests, five document intentional differences, and three cover the omitted +telemetry support. Reanalyze reports no unreviewed dead production code. + +OpenTelemetry is deliberately omitted, and source filters support the documented +common Rust/Re regular-expression subset rather than every Rust-regex construct. +No platform correctness defect remains open. Native Windows validation covers +the focused suite, all 42 OUnit2 cases, all 48 applicable canonical integration +cases, Rust's 136 unit tests, package promotion/inventory, and both packaged +executables. On a two-vCPU Windows 11 ARM64 guest running the x64 package under +emulation, five interleaved canonical clean builds measured 42.267 s for Rust +and 50.502 s for OCaml (1.195x). Seven-run no-op medians were 465 ms and 533 ms +(1.146x), and seven interleaved single-edit medians were 192 ms and 225 ms +(1.172x). These are low-core VM checkpoints, not portable absolute timings. + +## Documentation + +- This README is the maintained implementation, architecture, build, packaging, + and platform-status entry point. +- [`PARITY_CHECKLIST.md`](PARITY_CHECKLIST.md) is the behavior-by-behavior + contract and validation inventory. +- [`bench/README.md`](bench/README.md) documents reproducible performance, + filesystem-work, resource, artifact, and source-size gates. + +## Build + +From the repository root, with the dependencies declared in `rescript.opam` +installed: + +```sh +opam exec -- dune build rewatch-ocaml/rescript_ocaml.exe +``` + +The executable is written to: + +```text +_build/default/rewatch-ocaml/rescript_ocaml.exe +``` + +Linux release jobs use Dune's `static` profile. The rewatch executable declares +the same explicit `-ccopt -static` profile flag as the compiler executables, so +the npm artifact does not depend on the runner's glibc. A local Linux packaging +check can reproduce that link with: + +```sh +opam exec -- dune build --profile static rewatch-ocaml/rescript_ocaml.exe +file _build/default/rewatch-ocaml/rescript_ocaml.exe +``` + +It invokes `bsc` as an external process. When running outside this repository's +normal Makefile environment, point it at the compiler and runtime explicitly: + +```sh +export RESCRIPT_BSC_EXE="$PWD/_build/default/compiler/bsc/rescript_compiler_main.exe" +export RESCRIPT_RUNTIME="$PWD/packages/@rescript/runtime" +_build/default/rewatch-ocaml/rescript_ocaml.exe build path/to/project +``` + +On this experimental branch, published ReScript packages use the OCaml +implementation for the normal `rescript` command. The Rust reference +implementation remains available for side-by-side testing on every platform: + +```sh +npx rescript build +npx rescript-rust build +``` + +Windows ships the Dune-promoted OCaml executable as `rescript.exe` and the Rust +reference as `rescript-rust.exe`, matching the other platform packages. The +x64 package declares compatibility with ARM64 Windows because Windows runs +these x64 executables through its emulation layer; the launcher maps native +ARM64 Node to that package. + +The packaged executable discovers `bsc.exe` beside itself, like Rust rewatch, +and the npm launcher supplies the installed runtime path. Direct invocation can +instead resolve `@rescript/runtime` from the project hierarchy. The environment +variables above remain useful overrides for the dune development executable; +they are not required by the normal packaged launcher. + +Supported commands are `build` (the default), `watch`, `clean`, `format`, and +`compiler-args`. The CLI is declared with Cmdliner; run the executable with +`--help` for the current option summary. + +The binary version has a single OCaml source in `rewatch_version.ml`. +Repository releases synchronize it with the compiler and Rust rewatch versions +through `yarn constraints`; `yarn constraints --fix` updates all three from the +release version declared in `yarn.config.cjs`. + +The implementation is split by ownership rather than mirroring the Rust source +layout mechanically. Configuration types and the shared error identity live in +`config_types.ml`, duplicate-aware JSON primitives and structured field decoders +in `config_decode.ml`, and `config.ml` retains the top-level loader and +runtime/path queries. `file_util.ml` owns general portable path, directory, +copy, comparison, inventory, and removal operations; `build_artifacts.ml` owns +ReScript output paths, publication, ownership, and stale-artifact +cleanup. `package_graph.ml` owns package discovery; `package_parse.ml` and +`package_compilation.ml` own per-package parsing and compiler-job construction; +and `package_build.ml` sequences those phases. `build.ml` retains transaction +orchestration and aggregate dispatch, while `build_report.ml` owns presentation. +`build_preparation.ml` consumes the prepared packages to initialize compiler +context, clean stale assets, and run the preliminary parse; `module_graph.ml` +owns dependency resolution, graph-node identities, and cycle analysis. +`package_plan.ml` owns immutable per-package build inputs, `build_session.ml` +owns prepared state retained across watch rebuilds, and `build_attempt.ml` owns +attempt kinds, parse outcomes, diagnostics, counters, scheduled work, and final +cleanup for one build attempt. `source_dirs.ml` owns +source-directory metadata projection and serialization. `process_child.ml` +owns the lifecycle of one subprocess while `process.ml` owns scheduling. +Command-level post-build execution and its error handling live in +`after_build.ml`. +Genuinely platform-specific behavior is consolidated behind a `Platform` +boundary rather than mixed into those modules. Unix and Windows modules now own +executable lookup, subprocess creation, signal deferral, and process-tree +termination as well as lock-owner PID probing and capture-pipe creation. The +native watcher has a separate, narrow cross-platform boundary over libuv. +`watch_scope.ml` owns package/source selection and watch paths, +`watch_snapshot.ml` owns filesystem baselines and diffs, and `watcher.ml` owns +event reconciliation and rebuild lifecycle. Portable `Filename`-based path and +artifact logic remains shared. + +## Test + +```sh +opam exec -- dune runtest tests/rewatch_ounit_tests +rewatch-ocaml/tests/check_config_acceptance.sh +rewatch-ocaml/tests/check_command_validation.sh +rewatch-ocaml/tests/check_interactive_output.sh +rewatch-ocaml/tests/check_windows_job_stub.sh "$WINDOWS_CC" \ + "$WINDOWS_OCAML_INCLUDE" +sh rewatch-ocaml/tests/run.sh \ + "$PWD/_build/default/rewatch-ocaml/rescript_ocaml.exe" +``` + +The interactive-output parity gate requires the `script` PTY utility. Windows +CI skips that gate when `script` is unavailable; Windows output formatting is +still covered by unit tests, while redirected and verbose behavior is covered +by the platform-independent parity gates. + +The canonical integration suite can use the port through its existing override: + +```sh +export REWATCH_EXECUTABLE="$PWD/_build/default/rewatch-ocaml/rescript_ocaml.exe" +eval "$(cd rewatch/tests && node ./get_bin_paths.js)" +export RESCRIPT_BSC_EXE RESCRIPT_RUNTIME +bash rewatch/tests/compile/01-basic-compile.sh +``` + +## Packaging checks + +The release inventory uses the repository's existing Dune promotion and npm +artifact tooling. On Linux, build and verify the current platform package with: + +```sh +opam exec -- dune build --profile static compiler/sync/rescript.exe +file packages/@rescript/linux-arm64/bin/rescript.exe +node scripts/checkCompilerExes.js +node scripts/updateArtifactList.js +git diff --exit-code packages/artifacts.json +yarn workspace @rescript/linux-arm64 pack --json --dry-run +``` + +Use `linux-x64` instead on an x64 host. On Windows, build the Dune target and +run the same checks against `@rescript/win32-x64`; Corepack can invoke the final +command as `corepack yarn workspace @rescript/win32-x64 pack --json --dry-run`. +The package listing must contain the +OCaml `bin/rescript.exe`, the Rust reference `bin/rescript-rust.exe`, and both +rewatch notice files. CI runs the artifact-list check only after downloading +all platform builds and treats any missing declared executable as an error; +local manifest generation uses temporary placeholders solely for platforms +that were not built on the current machine. + +OpenTelemetry/OTLP tracing is intentionally not part of this port. This is an +explicit project scope decision, not a silently ignored configuration feature; +ordinary command output, verbosity, diagnostics, and exit statuses remain in +scope. + +## Platform status + +The Windows implementation is complete and natively validated. Windows ships +the OCaml implementation as the default `rescript.exe` and retains Rust as +`rescript-rust.exe`. Unix subprocesses use the cross-platform `spawn` library. +Windows uses a narrow native +`CreateProcessW` owner so it can establish Job Object ownership before a child +starts running. +Compiler output is captured through close-on-exec pipes drained by blocking +reader threads, avoiding reliance on Windows `select` support for anonymous +pipes. Each child is attached to a retained Windows Job Object so cancellation +can terminate descendants after the direct process has exited; the native stub +check above compiles that API boundary with warnings as errors when given a +Windows-targeting C compiler and its matching OCaml header directory. Watch mode +uses long-lived filesystem-event handles through Luv/libuv and retains the +snapshot-based polling loop only as a runtime fallback. Native tests exercise +Job assignment, descendant-held-pipe cancellation, lock-driven cancellation, +native process IDs and lock contention, watcher rebuild/recovery, formatting, +and post-build command execution. The MSYS harness cannot deliver a normal +Windows console-control event to a native child, so it exercises the same +cleanup path by removing `watch.lock`; console signal delivery remains covered +on Unix. Windows streaming `--after-build` commands inherit terminal stdin while +remaining in their Job Object. Unix terminal stdin is intentionally withheld +from these commands because their separately owned process group would be +stopped by `SIGTTIN`; redirected stdin is inherited, and full terminal input +requires a future PTY relay that preserves process-tree cancellation. Shared +path construction uses OCaml's `Filename` APIs so Windows +separators and drive roots are not hard-coded assumptions. diff --git a/rewatch-ocaml/after_build.ml b/rewatch-ocaml/after_build.ml new file mode 100644 index 00000000000..430b53afed2 --- /dev/null +++ b/rewatch-ocaml/after_build.ml @@ -0,0 +1,32 @@ +exception Error = Project_context.Error + +let run ?poll ~root command = + let program, args = + match Str.split (Str.regexp "[ \t\r\n]+") command with + | program :: args -> (program, args) + | [] -> raise (Error "--after-build command cannot be empty") + in + let result = + try Process.run_streaming ?poll ~cwd:root program args with + | Process.Error message -> + raise + (Error + (Printf.sprintf "Could not run --after-build command %S: %s" command + message)) + | Sys_error message -> + raise + (Error + (Printf.sprintf "Could not run --after-build command %S: %s" command + message)) + | Unix.Unix_error (error, operation, argument) -> + let target = if argument = "" then program else argument in + raise + (Error + (Printf.sprintf "Could not run --after-build command %S: %s (%s %s)" + command (Unix.error_message error) operation target)) + in + if not (Process.succeeded result) then + raise + (Error + (Printf.sprintf "--after-build command failed with %s" + (Process.status_string result.status))) diff --git a/rewatch-ocaml/after_build.mli b/rewatch-ocaml/after_build.mli new file mode 100644 index 00000000000..04e812c9fcb --- /dev/null +++ b/rewatch-ocaml/after_build.mli @@ -0,0 +1 @@ +val run : ?poll:(unit -> unit) -> root:string -> string -> unit diff --git a/rewatch-ocaml/ast_header.ml b/rewatch-ocaml/ast_header.ml new file mode 100644 index 00000000000..e5a8cc1c693 --- /dev/null +++ b/rewatch-ocaml/ast_header.ml @@ -0,0 +1,20 @@ +type t = {dependencies: string list; source: string option} + +let read path = + let descriptor = Unix.openfile path [Unix.O_RDONLY; Unix.O_CLOEXEC] 0 in + let channel = Unix.in_channel_of_descr descriptor in + Fun.protect + ~finally:(fun () -> close_in_noerr channel) + (fun () -> + (try ignore (input_line channel) with End_of_file -> ()); + let rec loop dependencies = + match input_line channel with + | line -> + let line = String.trim line in + if line = "" then loop dependencies + else if Filename.is_relative line then loop (line :: dependencies) + else {dependencies = List.rev dependencies; source = Some line} + | exception End_of_file -> + {dependencies = List.rev dependencies; source = None} + in + loop []) diff --git a/rewatch-ocaml/ast_header.mli b/rewatch-ocaml/ast_header.mli new file mode 100644 index 00000000000..3c41db44d24 --- /dev/null +++ b/rewatch-ocaml/ast_header.mli @@ -0,0 +1,7 @@ +type t = {dependencies: string list; source: string option} +(** Published AST headers are read without deserializing the compiler's full AST. + Cleanup needs the original source path, while graph construction needs only + dependency names; keeping one reader prevents those wire-format rules from + drifting. *) + +val read : string -> t diff --git a/rewatch-ocaml/bench/README.md b/rewatch-ocaml/bench/README.md new file mode 100644 index 00000000000..264a6455764 --- /dev/null +++ b/rewatch-ocaml/bench/README.md @@ -0,0 +1,212 @@ +# Performance and work-equivalence gate + +`performance_gate.sh` compares release builds on two fully isolated copies of +the tracked `rewatch/testrepo` fixture. It deliberately archives both the +fixture and its external Belt/runtime targets and copies all installed, +Git-ignored dependency trees (including nohoisted dependencies) separately +into each root. Cleaning one +implementation therefore cannot warm or remove artifacts used by the other. + +The gate performs one warm-up per implementation, at least five interleaved +clean builds, and reports median wall time, peak summed process-tree RSS, and +peak process-tree task count. Wall time and RSS are acceptance criteria; task +count is diagnostic evidence for subprocess-management overhead. It then traces +clean, unchanged, and single-edit builds with `strace` and requires identical +normalized package/phase/input multisets as well as identical counts for parser, +namespace, compiler, interface, and PPX process launches. The edit targets the +same leaf source in each isolated fixture. This sequence detects superfluous +incremental parsing or compilation that a clean-only comparison cannot expose. +Finally, both implementations clean and build a third fixture at the same +absolute path. The gate first requires the complete post-build +file-name sets to match, including auxiliary cache and editor-control files, +and then requires byte-identical generated JavaScript, compiler interfaces +(`.cmi`), JavaScript IR (`.cmj`), parser AST caches, namespace maps, copied +sources, source-directory metadata, and `build.ninja` markers, including those +below installed dependency trees. It does not treat typed debug metadata +(`.cmt`/`.cmti`), compiler logs, or `compiler-info.json` as byte-stable: those +contain compiler debug data, timestamps, or intentionally +implementation-specific state and are covered by file-set and integration +checks instead. The default +acceptance threshold requires both OCaml medians to be no more than 125% of +Rust. + +The post-rebase powered, idle-host seven-run gate measured a 4.740 s Rust +clean-build median and a 5.020 s OCaml median (1.059x). Median summed +process-tree RSS was 1,066,940 KiB and 1,184,740 KiB respectively (1.110x). +Clean, unchanged, and one-edit compiler work matched exactly, and the complete +post-build file sets and byte-stable artifacts were identical. The companion +seven-edit retained-watch gate measured 130 ms for Rust and 153 ms for OCaml +(1.177x), with identical compiler work and stable resources. These values are +a reproducible checkpoint, not portable absolute expectations. + +This is one part of equivalence checking, not a substitute for the test suites. +Before accepting a performance increment, also run the OCaml unit/focused tests +and the canonical Rust rewatch integration suite against the OCaml executable: + +```sh +opam exec -- dune runtest tests/rewatch_ounit_tests +bash rewatch-ocaml/tests/run.sh \ + _build/default/rewatch-ocaml/rescript_ocaml.exe +(cd rewatch/tests && \ + bash ./suite.sh ../../_build/default/rewatch-ocaml/rescript_ocaml.exe) +``` + +Together these cover three different failure classes: + +- the canonical and focused suites check observable command/build/watch + behavior; +- the three `strace` classifications check that a speed result did not hide + skipped or superfluous clean/incremental module or PPX work (argument + semantics remain covered by the compiler-argument and integration tests); +- the fresh-tree comparisons check the complete post-build file set plus the + byte contents of every stable generated artifact class. + +The manifest comparison intentionally recreates its fixture between runners. +Using only each implementation's `clean` command would allow a Rust-only file +to survive into the OCaml run and could conceal a missing-output bug. + +`filesystem_audit.sh` writes normalized `*.categories.tsv`, `*.paths.tsv`, and +`*.processes.tsv` files when `KEEP_REWATCH_FILESYSTEM_AUDIT=1` is set. The last +form attributes each path operation category to the executable recorded for +that traced process, separating driver work from compiler, PPX, and helper +work. Processes which inherit a trace file without a subsequent `execve` are +reported as `inherited-process`; do not assume those calls belong to the +driver without inspecting the raw trace. + +Build both release executables and run: + +```sh +cargo build --manifest-path rewatch/Cargo.toml --release +opam exec -- dune build --profile release rewatch-ocaml/rescript_ocaml.exe + +rewatch-ocaml/bench/performance_gate.sh \ + rewatch/target/release/rescript \ + _build/default/rewatch-ocaml/rescript_ocaml.exe \ + 5 +``` + +By default the harness uses the compiler and runtime selected by +`rewatch/tests/get_bin_paths.js`. Set both `RESCRIPT_BSC_EXE` and +`RESCRIPT_RUNTIME` to compare with another local compiler build; the harness +preserves them only when both are set, so the two implementations still use +the same inputs. + +The authoritative gate requires Linux (`/proc`), `strace`, GNU-compatible +nanosecond `date`, and a stable plugged-in host with no competing heavy work. +Keep the isolated fixtures on a case-sensitive Linux filesystem. A Linux +container backed by a case-insensitive macOS bind mount can transiently report +that a differently-cased recreated CMI exists to `stat` and then return +`ENOENT` from the immediately following `open`; that host-filesystem artifact +is not valid scheduler or benchmark evidence. The harness's default `mktemp` +workspace normally stays on the container filesystem. +Set `REWATCH_PERFORMANCE_THRESHOLD_PERCENT` to exercise a proposed threshold +change; changing the committed 125% completion criterion requires an explicit +project decision. Set `KEEP_REWATCH_BENCHMARK_WORKDIR=1` to retain traces and raw +stdout/stderr for investigation. For a quick correctness-only check, an odd run +count below five is accepted only with `REWATCH_ALLOW_SMOKE_RUN=1`; its timing +must never be treated as a quality-gate result. + +## Filesystem-work audit + +Pipe-based subprocess capture has removed the intentional temporary capture +files. Run the second orchestration audit on Linux with: + +```sh +rewatch-ocaml/bench/filesystem_audit.sh \ + rewatch/target/release/rescript \ + _build/default/rewatch-ocaml/rescript_ocaml.exe +``` + +It traces isolated clean, unchanged, and single-edit builds with `strace`, +normalizes each fixture root, retains operations whose target is inside that +root, and reports per-path operation multisets plus metadata, open, +directory-scan, create, rename, remove, and execute categories. Set +`KEEP_REWATCH_FILESYSTEM_AUDIT=1` to retain normalized manifests and raw traces +for investigation. + +Do not gate on the raw process-wide syscall total: Rust, OCaml, libc, the +dynamic loader, and subprocess startup legitimately perform different +toolchain-level accesses. Report those separately, and treat repeated accesses +to the same project artifact or discovery path as the primary evidence of +superfluous orchestration work. The existing compiler-work and artifact checks +must remain enabled so fewer filesystem calls cannot conceal skipped work. + +Process attribution showed identical compiler-subprocess +metadata/open work and effectively equal driver-plus-inherited clean-build open +counts (about 8,110 for OCaml and 8,120 for Rust). OCaml made about 2,387 more +driver-side metadata calls, led by repeated checks of source and `lib/bs` +directories during artifact publication. Unchanged and single-edit process +runs used fewer metadata and open calls in OCaml. The clean-build difference is +therefore understood rather than an unexplained algorithmic discrepancy. A +future cache for already-created publication directories may remove it, but it +must be scoped to one attempt and recover correctly if a directory is removed +concurrently. Preserve the raw trace or repeat the process-attributed audit +before making that tradeoff. + +For the ordinary-edit path inside one long-lived watcher, run: + +```sh +rewatch-ocaml/bench/watch_filesystem_audit.sh \ + rewatch/target/release/rescript \ + _build/default/rewatch-ocaml/rescript_ocaml.exe +``` + +This starts each implementation on an isolated small project, waits for its +initial build hook, traces one dependency-preserving source edit, and stops the +watcher through its lock file. Only calls timestamped between the edit and the +successful incremental-build hook enter the normalized reports, so initial +discovery and shutdown do not obscure retained-state work. Set +`KEEP_REWATCH_WATCH_AUDIT=1` to retain raw traces, normalized path/category +tables, process attribution, and command output. As with the short-lived audit, +project-local repeated paths and compiler work are the useful comparison; raw +runtime-wide syscall totals are diagnostic rather than an acceptance limit. + +The retained-watch performance and resource gate exercises several ordinary +edits through the same long-lived watcher: + +```sh +rewatch-ocaml/bench/watch_performance_gate.sh \ + rewatch/target/release/rescript \ + _build/default/rewatch-ocaml/rescript_ocaml.exe \ + 7 +``` + +It warms both implementations, interleaves an odd number of timed edits, +requires byte-identical generated JavaScript and normalized parser/compiler +argument logs, and samples file descriptors, tasks, and RSS after every build. +This catches retained-state implementations that appear fast by skipping work, +as well as resource growth that a one-event syscall trace cannot show. The +default median-latency limit is 150% of Rust because individual watch events +include operating-system notification and 50 ms polling intervals; override it +with `REWATCH_WATCH_PERFORMANCE_THRESHOLD_PERCENT` only for investigation. +The build gate's lower-noise 125% clean/incremental threshold remains the +authoritative general performance criterion. Set +`KEEP_REWATCH_WATCH_PERFORMANCE=1` to retain output, compiler-call logs, +latencies, and fixtures. This gate requires Linux `/proc`, GNU-compatible +millisecond `date`, and `setsid`. + +## Source-size snapshot + +Run `bench/source_size.sh` with `cloc` installed to record a reproducible +maintainability snapshot. The production comparison excludes Rust's explicitly +out-of-scope telemetry module and reports its inline `#[cfg(test)]` sections as +tests rather than implementation. Both OCaml platform backends count because +both remain maintained production source. All tracked OCaml test harnesses, +fixtures, and configuration files are reported together but separately from +implementation; benchmark tooling includes every executable shell/JavaScript +file in `bench`, including this counting script itself. The report also lists +the ten largest production modules and test/tooling files so growth and mixed +responsibilities are visible without treating line count as a target. Record +the `cloc` version with the result and rerun this at the final maintainability +review. + +Source lines are an observation, not an acceptance threshold. A smaller port +can indicate less machinery, but missing compatibility, weak tests, compressed +code, or too few explanatory comments can also reduce the number. Behavioral +and work equivalence, platform support, performance, module size, and review +findings remain the actual quality gates. + +The post-Windows snapshot with cloc 2.04 contains 7,809 Rust production lines +after excluding telemetry and 11,320 OCaml production lines including both +platform backends. Rust inline unit tests account for 2,773 lines; OCaml tests +and fixtures account for 10,270 lines, and the benchmark tooling for 1,033. diff --git a/rewatch-ocaml/bench/filesystem_audit.sh b/rewatch-ocaml/bench/filesystem_audit.sh new file mode 100755 index 00000000000..19a1ffc92b3 --- /dev/null +++ b/rewatch-ocaml/bench/filesystem_audit.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 RUST_REWATCH OCAML_REWATCH" >&2 + exit 2 +fi + +repo_root=$(cd "$(dirname "$0")/../.." && pwd) +rust_executable=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") +ocaml_executable=$(cd "$(dirname "$2")" && pwd)/$(basename "$2") +normalizer="$repo_root/rewatch-ocaml/bench/normalize_file_trace.js" + +for command in basename cp dirname find git join mktemp node sed sort strace tar; do + command -v "$command" >/dev/null || { + echo "Missing required command: $command" >&2 + exit 2 + } +done +if [[ ! -x "$rust_executable" || ! -x "$ocaml_executable" ]]; then + echo "Both rewatch executables must exist and be executable." >&2 + exit 2 +fi + +work_root=$(mktemp -d "${TMPDIR:-/tmp}/rewatch-filesystem-audit.XXXXXX") +cleanup() { + if [[ ${KEEP_REWATCH_FILESYSTEM_AUDIT:-0} == 1 ]]; then + echo "Kept filesystem audit workdir: $work_root" >&2 + else + find "$work_root" -depth -delete + fi +} +trap cleanup EXIT INT TERM + +prepare_fixture() { + local destination=$1 + mkdir -p "$destination" + git -C "$repo_root" archive HEAD \ + rewatch/testrepo packages/@rescript/belt packages/@rescript/runtime \ + | tar -x -C "$destination" + while IFS= read -r dependency_tree; do + local relative_tree=${dependency_tree#"$repo_root/"} + mkdir -p "$(dirname "$destination/$relative_tree")" + cp -a --reflink=auto "$dependency_tree" "$destination/$relative_tree" + done < <(find "$repo_root/rewatch/testrepo" -type d -name node_modules \ + -prune -print) +} + +if [[ -z ${RESCRIPT_BSC_EXE:-} || -z ${RESCRIPT_RUNTIME:-} ]]; then + eval "$(cd "$repo_root/rewatch/tests" && node ./get_bin_paths.js)" +fi +export RESCRIPT_BSC_EXE RESCRIPT_RUNTIME + +rust_root="$work_root/rust" +ocaml_root="$work_root/ocaml" +prepare_fixture "$rust_root" +prepare_fixture "$ocaml_root" +rust_fixture="$rust_root/rewatch/testrepo" +ocaml_fixture="$ocaml_root/rewatch/testrepo" + +trace_build() { + local implementation=$1 scenario=$2 executable=$3 fixture=$4 clean_first=$5 + local trace_prefix="$work_root/$implementation-$scenario.file" + local normalized="$work_root/$implementation-$scenario" + if [[ "$clean_first" == 1 ]]; then + "$executable" clean "$fixture" >/dev/null 2>&1 + fi + ( + cd "$fixture" + strace -f -ff -qq -yy -s 4096 -e trace=%file,getdents64 \ + -o "$trace_prefix" "$executable" build . \ + >"$normalized.stdout" 2>"$normalized.stderr" + ) + node "$normalizer" "$trace_prefix" "$fixture" "$normalized" +} + +for scenario in clean unchanged; do + clean_first=0 + [[ "$scenario" == clean ]] && clean_first=1 + trace_build rust "$scenario" "$rust_executable" "$rust_fixture" "$clean_first" + trace_build ocaml "$scenario" "$ocaml_executable" "$ocaml_fixture" "$clean_first" +done + +printf '\n// filesystem audit single edit\n' \ + >>"$rust_fixture/packages/watch-warnings/src/B.res" +printf '\n// filesystem audit single edit\n' \ + >>"$ocaml_fixture/packages/watch-warnings/src/B.res" +trace_build rust edit "$rust_executable" "$rust_fixture" 0 +trace_build ocaml edit "$ocaml_executable" "$ocaml_fixture" 0 + +for scenario in clean unchanged edit; do + echo + echo "$scenario project filesystem categories (Rust / OCaml)" + join -a 1 -a 2 -e 0 -o 0,1.2,2.2 \ + "$work_root/rust-$scenario.categories.tsv" \ + "$work_root/ocaml-$scenario.categories.tsv" + echo "$scenario most repeated OCaml project path operations" + sort -t $'\t' -k1,1nr "$work_root/ocaml-$scenario.paths.tsv" | sed -n '1,20p' +done + +echo +echo "This audit is diagnostic: inspect repeated project-local accesses and the" +echo "retained manifests; raw Rust/OCaml totals are not an equivalence gate." diff --git a/rewatch-ocaml/bench/normalize_file_trace.js b/rewatch-ocaml/bench/normalize_file_trace.js new file mode 100755 index 00000000000..7506651c76f --- /dev/null +++ b/rewatch-ocaml/bench/normalize_file_trace.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +if (process.argv.length !== 5 && process.argv.length !== 7) { + console.error( + "Usage: normalize_file_trace.js TRACE_PREFIX FIXTURE OUTPUT_PREFIX [START_EPOCH END_EPOCH]", + ); + process.exit(2); +} + +const [, , tracePrefix, fixtureArgument, outputPrefix, startArgument, endArgument] = + process.argv; +const startEpoch = + startArgument === undefined ? Number.NEGATIVE_INFINITY : Number(startArgument); +const endEpoch = endArgument === undefined ? Number.POSITIVE_INFINITY : Number(endArgument); +if (Number.isNaN(startEpoch) || Number.isNaN(endEpoch) || startEpoch > endEpoch) { + console.error("Trace epoch bounds must be ordered numbers."); + process.exit(2); +} +const fixture = path.resolve(fixtureArgument); +const traceDirectory = path.dirname(tracePrefix); +const traceBasename = `${path.basename(tracePrefix)}.`; +const traces = fs + .readdirSync(traceDirectory) + .filter((name) => name.startsWith(traceBasename)) + .sort(); + +const operations = []; +const categories = new Map(); +const processCategories = new Map(); + +function decodeQuoted(value) { + try { + return JSON.parse(`"${value}"`); + } catch (_) { + return value; + } +} + +function normalize(cwd, value) { + if (value === "") return null; + const absolute = path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value); + if (absolute !== fixture && !absolute.startsWith(`${fixture}${path.sep}`)) return null; + const relative = path.relative(fixture, absolute); + return relative === "" ? "" : `/${relative.split(path.sep).join("/")}`; +} + +function category(operation) { + if (/^(open|openat|openat2|creat)$/.test(operation)) return "open"; + if (/^(stat|statx|lstat|fstatat|newfstatat|access|faccessat|faccessat2|readlink|readlinkat)$/.test(operation)) return "metadata"; + if (/^(mkdir|mkdirat|mknod|mknodat|link|linkat|symlink|symlinkat)$/.test(operation)) return "create"; + if (/^rename/.test(operation)) return "rename"; + if (/^(unlink|unlinkat|rmdir)$/.test(operation)) return "remove"; + if (/^getdents/.test(operation)) return "directory-scan"; + if (operation === "execve") return "execute"; + return "other"; +} + +function pathValues(operation, line) { + if (/^getdents/.test(operation)) { + const descriptorPath = line.match(/^getdents\w*\(\d+<([^>]+)>/); + return descriptorPath ? [descriptorPath[1]] : []; + } + const quoted = [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((match) => + decodeQuoted(match[1]), + ); + if (/^(rename|renameat|renameat2|link|linkat)$/.test(operation)) return quoted.slice(0, 2); + if (/^(symlink|symlinkat)$/.test(operation)) return quoted.slice(-1); + return quoted.slice(0, 1); +} + +for (const trace of traces) { + let cwd = fixture; + const lines = fs.readFileSync(path.join(traceDirectory, trace), "utf8").split("\n"); + const executable = lines + .map((line) => line.replace(/^\d+\.\d+\s+/, "")) + .map((line) => line.match(/^execve\("((?:[^"\\]|\\.)*)"/)) + .find((match) => match !== null); + const processName = executable + ? path.basename(decodeQuoted(executable[1])) + : "inherited-process"; + for (const rawLine of lines) { + const timed = rawLine.match(/^(\d+\.\d+)\s+(.*)$/); + const timestamp = timed === null ? null : Number(timed[1]); + const line = timed === null ? rawLine : timed[2]; + const call = line.match(/^([a-zA-Z0-9_]+)\(/); + if (!call) continue; + const operation = call[1]; + const values = pathValues(operation, line); + const callCwd = cwd; + for (const value of values) { + const normalized = normalize(callCwd, value); + if (normalized === null) continue; + if ( + timestamp === null || + (timestamp >= startEpoch && timestamp <= endEpoch) + ) { + operations.push(`${operation}\t${normalized}`); + const name = category(operation); + categories.set(name, (categories.get(name) || 0) + 1); + const processKey = `${processName}\t${name}`; + processCategories.set(processKey, (processCategories.get(processKey) || 0) + 1); + } + } + if (operation === "chdir" && line.endsWith("= 0") && values.length === 1) { + cwd = path.isAbsolute(values[0]) + ? path.normalize(values[0]) + : path.resolve(callCwd, values[0]); + } + } +} + +operations.sort(); +const counted = []; +for (let index = 0; index < operations.length; ) { + let end = index + 1; + while (end < operations.length && operations[end] === operations[index]) end += 1; + counted.push(`${end - index}\t${operations[index]}`); + index = end; +} +fs.writeFileSync(`${outputPrefix}.paths.tsv`, `${counted.join("\n")}\n`); +fs.writeFileSync( + `${outputPrefix}.categories.tsv`, + `${[...categories].sort().map(([name, count]) => `${name}\t${count}`).join("\n")}\n`, +); +fs.writeFileSync( + `${outputPrefix}.processes.tsv`, + `${[...processCategories] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, count]) => `${key}\t${count}`) + .join("\n")}\n`, +); diff --git a/rewatch-ocaml/bench/performance_gate.sh b/rewatch-ocaml/bench/performance_gate.sh new file mode 100755 index 00000000000..4118d7fc651 --- /dev/null +++ b/rewatch-ocaml/bench/performance_gate.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 || $# -gt 3 ]]; then + echo "Usage: $0 RUST_REWATCH OCAML_REWATCH [RUNS]" >&2 + exit 2 +fi + +repo_root=$(cd "$(dirname "$0")/../.." && pwd) +rust_executable=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") +ocaml_executable=$(cd "$(dirname "$2")" && pwd)/$(basename "$2") +runs=${3:-5} +threshold_percent=${REWATCH_PERFORMANCE_THRESHOLD_PERCENT:-125} + +if [[ ! -x "$rust_executable" || ! -x "$ocaml_executable" ]]; then + echo "Both rewatch executables must exist and be executable." >&2 + exit 2 +fi +if [[ ! "$runs" =~ ^[1-9][0-9]*$ || $((runs % 2)) -eq 0 ]]; then + echo "RUNS must be a positive odd integer so the median is unambiguous." >&2 + exit 2 +fi +if ((runs < 5)) && [[ ${REWATCH_ALLOW_SMOKE_RUN:-0} != 1 ]]; then + echo "RUNS must be at least 5 for the quality gate." >&2 + echo "Set REWATCH_ALLOW_SMOKE_RUN=1 only for a non-authoritative smoke run." >&2 + exit 2 +fi +for command in awk basename cmp cp date diff dirname find getconf git grep head \ + mktemp node ps sed sha256sum sleep sort strace tar uname xargs; do + command -v "$command" >/dev/null || { + echo "Missing required command: $command" >&2 + exit 2 + } +done +if [[ ! -d /proc ]]; then + echo "This gate requires Linux /proc for process-tree RSS sampling." >&2 + exit 2 +fi + +work_root=$(mktemp -d "${TMPDIR:-/tmp}/rewatch-performance.XXXXXX") +cleanup() { + if [[ ${KEEP_REWATCH_BENCHMARK_WORKDIR:-0} == 1 ]]; then + echo "Kept benchmark workdir: $work_root" >&2 + else + find "$work_root" -depth -delete + fi +} +trap cleanup EXIT INT TERM + +prepare_fixture() { + local destination=$1 + mkdir -p "$destination" + git -C "$repo_root" archive HEAD \ + rewatch/testrepo packages/@rescript/belt packages/@rescript/runtime \ + | tar -x -C "$destination" + # Dependencies and workspace links are intentionally ignored by Git. Keep a + # separate installed tree in each root so neither implementation can affect + # the other's generated dependency artifacts. + while IFS= read -r dependency_tree; do + local relative_tree=${dependency_tree#"$repo_root/"} + mkdir -p "$(dirname "$destination/$relative_tree")" + cp -a --reflink=auto "$dependency_tree" "$destination/$relative_tree" + done < <(find "$repo_root/rewatch/testrepo" -type d -name node_modules \ + -prune -print) +} + +rust_root="$work_root/rust" +ocaml_root="$work_root/ocaml" +prepare_fixture "$rust_root" +prepare_fixture "$ocaml_root" +rust_fixture="$rust_root/rewatch/testrepo" +ocaml_fixture="$ocaml_root/rewatch/testrepo" + +if [[ -z ${RESCRIPT_BSC_EXE:-} || -z ${RESCRIPT_RUNTIME:-} ]]; then + eval "$(cd "$repo_root/rewatch/tests" && node ./get_bin_paths.js)" +fi +export RESCRIPT_BSC_EXE RESCRIPT_RUNTIME + +results="$work_root/results.csv" +echo "implementation,iteration,wall_ms,peak_tree_rss_kib,peak_tree_tasks" \ + >"$results" + +tree_resources() { + local root_pid=$1 + ps -e -o pid=,ppid=,rss=,nlwp= | awk -v root="$root_pid" ' + { pids[NR] = $1; parent[$1] = $2; memory[$1] = $3; tasks[$1] = $4 } + END { + live[root] = 1 + for (pass = 0; pass < NR; pass++) + for (i = 1; i <= NR; i++) + if (live[parent[pids[i]]]) live[pids[i]] = 1 + for (pid in live) { + total_memory += memory[pid] + total_tasks += tasks[pid] + } + print total_memory + 0, total_tasks + 0 + }' +} + +clean_and_build() { + local executable=$1 fixture=$2 output=$3 + "$executable" clean "$fixture" >/dev/null 2>&1 + "$executable" build "$fixture" >"$output" 2>"$output.stderr" +} + +measure() { + local implementation=$1 executable=$2 fixture=$3 iteration=$4 + local output="$work_root/${implementation}-${iteration}" + "$executable" clean "$fixture" >/dev/null 2>&1 + local start_ns root_pid peak_rss=0 peak_tasks=0 rss tasks end_ns wall_ms + start_ns=$(date +%s%N) + "$executable" build "$fixture" >"$output" 2>"$output.stderr" & + root_pid=$! + while kill -0 "$root_pid" 2>/dev/null; do + read -r rss tasks < <(tree_resources "$root_pid") + if ((rss > peak_rss)); then + peak_rss=$rss + fi + if ((tasks > peak_tasks)); then + peak_tasks=$tasks + fi + sleep 0.02 + done + wait "$root_pid" + end_ns=$(date +%s%N) + wall_ms=$(((end_ns - start_ns) / 1000000)) + echo "$implementation,$iteration,$wall_ms,$peak_rss,$peak_tasks" >>"$results" + printf '%-5s run %d: %6d ms %8d KiB %4d tasks\n' \ + "$implementation" "$iteration" "$wall_ms" "$peak_rss" "$peak_tasks" +} + +median_column() { + local implementation=$1 column=$2 middle=$((runs / 2 + 1)) + awk -F, -v implementation="$implementation" \ + '$1 == implementation { print $'"$column"' }' "$results" \ + | sort -n | sed -n "${middle}p" +} + +echo "Rewatch clean-build performance gate" +echo "commit: $(git -C "$repo_root" rev-parse HEAD)" +echo "host: $(uname -a)" +echo "cpus: $(getconf _NPROCESSORS_ONLN 2>/dev/null || echo unknown)" +echo "runs: $runs (interleaved after one warm-up each)" +echo "threshold: ${threshold_percent}% of Rust median wall and RSS" + +clean_and_build "$rust_executable" "$rust_fixture" "$work_root/rust-warmup" +clean_and_build "$ocaml_executable" "$ocaml_fixture" "$work_root/ocaml-warmup" + +for ((iteration = 1; iteration <= runs; iteration++)); do + if ((iteration % 2 == 1)); then + measure rust "$rust_executable" "$rust_fixture" "$iteration" + measure ocaml "$ocaml_executable" "$ocaml_fixture" "$iteration" + else + measure ocaml "$ocaml_executable" "$ocaml_fixture" "$iteration" + measure rust "$rust_executable" "$rust_fixture" "$iteration" + fi +done + +rust_wall=$(median_column rust 3) +ocaml_wall=$(median_column ocaml 3) +rust_rss=$(median_column rust 4) +ocaml_rss=$(median_column ocaml 4) +rust_tasks=$(median_column rust 5) +ocaml_tasks=$(median_column ocaml 5) +printf 'median Rust: %6d ms %8d KiB %4d peak tasks\n' \ + "$rust_wall" "$rust_rss" "$rust_tasks" +printf 'median OCaml: %6d ms %8d KiB %4d peak tasks\n' \ + "$ocaml_wall" "$ocaml_rss" "$ocaml_tasks" + +trace_and_classify() { + local implementation=$1 scenario=$2 executable=$3 fixture=$4 manifest=$5 + local clean_first=$6 + local trace_prefix="$work_root/${implementation}-${scenario}.execve" + if [[ "$clean_first" == 1 ]]; then + "$executable" clean "$fixture" >/dev/null 2>&1 + fi + strace -f -ff -qq -s 4096 -e trace=execve,chdir -o "$trace_prefix" \ + "$executable" build "$fixture" \ + >"$work_root/${implementation}-${scenario}-trace.out" \ + 2>"$work_root/${implementation}-${scenario}-trace.stderr" + local trace_files=("$trace_prefix".*) + local implementation_root=${fixture%/rewatch/testrepo} + local trace_file exec_line argv cwd_line cwd phase input identity + : >"$manifest.unsorted" + for trace_file in "${trace_files[@]}"; do + exec_line=$(grep -m1 -F "execve(\"$RESCRIPT_BSC_EXE\"" "$trace_file" \ + || grep -m1 -E 'execve\("[^"]*sury-ppx' "$trace_file" || true) + if [[ -z "$exec_line" ]]; then + continue + fi + argv=${exec_line#*, } + argv=${argv%%], 0x*}] + cwd_line=$(grep -m1 '^chdir("' "$trace_file" || true) + cwd=${cwd_line#chdir(\"} + cwd=${cwd%%\"*} + if [[ "$exec_line" == *"execve(\"$RESCRIPT_BSC_EXE\""* ]]; then + if [[ "$argv" == *'"-bs-ast"'* ]]; then + phase=parse + elif [[ "$argv" == *'.mlmap"'* ]]; then + phase=namespace + else + phase=compile + fi + input=${argv##*, } + input=${input%]} + identity="$cwd"$'\t'"$phase"$'\t'"$input" + else + # PPX temporary input/output names are deliberately randomized. Its + # executable identity and count are the stable unit of work. + identity=ppx$'\t'"${argv%%,*}" + fi + printf '%s\n' "$identity" \ + | sed "s#$implementation_root##g" >>"$manifest.unsorted" + done + sort "$manifest.unsorted" >"$manifest" + local invocations parse namespace compile interface ppx + invocations=$(grep -hF -c "execve(\"$RESCRIPT_BSC_EXE\"" "${trace_files[@]}" \ + | awk '{ total += $1 } END { print total + 0 }') + parse=$(grep -hF "execve(\"$RESCRIPT_BSC_EXE\"" "${trace_files[@]}" \ + | grep -F -c '"-bs-ast"' || true) + namespace=$(grep -hF "execve(\"$RESCRIPT_BSC_EXE\"" "${trace_files[@]}" \ + | grep -E -c '\.mlmap"' || true) + interface=$(grep -hF "execve(\"$RESCRIPT_BSC_EXE\"" "${trace_files[@]}" \ + | grep -vF '"-bs-ast"' | grep -vE '\.mlmap"' \ + | grep -E -c '\.iast"' || true) + compile=$((invocations - parse - namespace)) + ppx=$(grep -hE -c 'execve\("[^"]*sury-ppx' "${trace_files[@]}" \ + | awk '{ total += $1 } END { print total + 0 }') + echo "$invocations,$parse,$namespace,$compile,$interface,$ppx" +} + +rust_clean_invocations="$work_root/rust-clean-invocations.txt" +ocaml_clean_invocations="$work_root/ocaml-clean-invocations.txt" +rust_clean_work=$(trace_and_classify rust clean "$rust_executable" \ + "$rust_fixture" "$rust_clean_invocations" 1) +ocaml_clean_work=$(trace_and_classify ocaml clean "$ocaml_executable" \ + "$ocaml_fixture" "$ocaml_clean_invocations" 1) + +rust_unchanged_invocations="$work_root/rust-unchanged-invocations.txt" +ocaml_unchanged_invocations="$work_root/ocaml-unchanged-invocations.txt" +rust_unchanged_work=$(trace_and_classify rust unchanged "$rust_executable" \ + "$rust_fixture" "$rust_unchanged_invocations" 0) +ocaml_unchanged_work=$(trace_and_classify ocaml unchanged "$ocaml_executable" \ + "$ocaml_fixture" "$ocaml_unchanged_invocations" 0) + +printf '\n// benchmark single edit\n' \ + >>"$rust_fixture/packages/watch-warnings/src/B.res" +printf '\n// benchmark single edit\n' \ + >>"$ocaml_fixture/packages/watch-warnings/src/B.res" +rust_edit_invocations="$work_root/rust-edit-invocations.txt" +ocaml_edit_invocations="$work_root/ocaml-edit-invocations.txt" +rust_edit_work=$(trace_and_classify rust edit "$rust_executable" \ + "$rust_fixture" "$rust_edit_invocations" 0) +ocaml_edit_work=$(trace_and_classify ocaml edit "$ocaml_executable" \ + "$ocaml_fixture" "$ocaml_edit_invocations" 0) + +echo "work columns: bsc_total,parse,namespace,compile,interfaces,ppx" +echo "clean Rust: $rust_clean_work" +echo "clean OCaml: $ocaml_clean_work" +echo "unchanged Rust: $rust_unchanged_work" +echo "unchanged OCaml: $ocaml_unchanged_work" +echo "edit Rust: $rust_edit_work" +echo "edit OCaml: $ocaml_edit_work" + +artifact_manifest() { + local root=$1 output=$2 + find "$root" -type f \ + \( -name '*.ast' -o -name '*.iast' -o -name '*.cmi' -o -name '*.cmj' \ + -o -name '*.mlmap' \ + -o -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.map' \ + -o -name '*.res' -o -name '*.resi' -o -name '.sourcedirs.json' \ + -o -name build.ninja \) \ + ! -name '.compiler.log' ! -name compiler-info.json -print0 \ + | sort -z | xargs -0 sha256sum | sed "s#$root/##" >"$output" +} + +file_set_manifest() { + local root=$1 output=$2 + find "$root" -type f -printf '%P\n' | sort >"$output" +} + +# Use the same absolute path for both builds so paths embedded in binary +# compiler artifacts are directly comparable byte for byte. +equivalence_root="$work_root/equivalence" +prepare_fixture "$equivalence_root" +equivalence_fixture="$equivalence_root/rewatch/testrepo" +rust_artifacts="$work_root/rust-artifacts.sha256" +ocaml_artifacts="$work_root/ocaml-artifacts.sha256" +rust_files="$work_root/rust-files.txt" +ocaml_files="$work_root/ocaml-files.txt" +clean_and_build "$rust_executable" "$equivalence_fixture" \ + "$work_root/rust-equivalence" +artifact_manifest "$equivalence_root" "$rust_artifacts" +file_set_manifest "$equivalence_root" "$rust_files" +# Recreate, rather than clean, the fixture so OCaml cannot inherit an artifact +# that only Rust produced. Reusing the same pathname keeps embedded paths equal. +find "$equivalence_root" -depth -delete +prepare_fixture "$equivalence_root" +clean_and_build "$ocaml_executable" "$equivalence_fixture" \ + "$work_root/ocaml-equivalence" +artifact_manifest "$equivalence_root" "$ocaml_artifacts" +file_set_manifest "$equivalence_root" "$ocaml_files" +if cmp -s "$rust_files" "$ocaml_files"; then + file_set_equivalence=1 + echo "files: identical complete post-build file sets" +else + file_set_equivalence=0 + echo "complete post-build file-set diff:" >&2 + diff -u "$rust_files" "$ocaml_files" >&2 || true +fi +if cmp -s "$rust_artifacts" "$ocaml_artifacts"; then + artifact_equivalence=1 + echo "artifacts: identical generated file sets and contents" +else + artifact_equivalence=0 + echo "artifact manifest diff:" >&2 + diff -u "$rust_artifacts" "$ocaml_artifacts" >&2 || true +fi + +failed=0 +if ((runs >= 5 && ocaml_wall * 100 > rust_wall * threshold_percent)); then + echo "FAIL: OCaml median wall time exceeds the threshold." >&2 + failed=1 +fi +if ((runs >= 5 && ocaml_rss * 100 > rust_rss * threshold_percent)); then + echo "FAIL: OCaml median peak tree RSS exceeds the threshold." >&2 + failed=1 +fi +for scenario in clean unchanged edit; do + rust_work_variable="rust_${scenario}_work" + ocaml_work_variable="ocaml_${scenario}_work" + rust_manifest_variable="rust_${scenario}_invocations" + ocaml_manifest_variable="ocaml_${scenario}_invocations" + rust_work_value=${!rust_work_variable} + ocaml_work_value=${!ocaml_work_variable} + rust_manifest=${!rust_manifest_variable} + ocaml_manifest=${!ocaml_manifest_variable} + if [[ "$rust_work_value" != "$ocaml_work_value" ]]; then + echo "FAIL: Rust and OCaml performed different $scenario compiler work." >&2 + failed=1 + fi + if ! cmp -s "$rust_manifest" "$ocaml_manifest"; then + echo "FAIL: Rust and OCaml performed different $scenario module/PPX work." >&2 + diff -u "$rust_manifest" "$ocaml_manifest" >&2 || true + failed=1 + fi +done +if ((file_set_equivalence == 0)); then + echo "FAIL: Rust and OCaml produced different post-build file sets." >&2 + failed=1 +fi +if ((artifact_equivalence == 0)); then + echo "FAIL: Rust and OCaml generated different artifacts." >&2 + failed=1 +fi + +if ((failed)); then + exit 1 +fi +if ((runs < 5)); then + echo "PASS: correctness smoke checks passed; performance gate not evaluated." +else + echo "PASS: timing, memory, compiler-work, and artifact-equivalence gates passed." +fi diff --git a/rewatch-ocaml/bench/source_size.sh b/rewatch-ocaml/bench/source_size.sh new file mode 100755 index 00000000000..906a1507e2e --- /dev/null +++ b/rewatch-ocaml/bench/source_size.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root=$(cd "$(dirname "$0")/../.." && pwd) +cloc_command=${CLOC:-cloc} + +if ! command -v "$cloc_command" >/dev/null 2>&1; then + echo "source_size.sh requires cloc (or set CLOC to its executable)" >&2 + exit 1 +fi + +work_dir=$(mktemp -d) +trap 'rm -r "$work_dir"' EXIT + +rust_production="$work_dir/rust-production" +rust_tests="$work_dir/rust-tests" +mkdir -p "$rust_production" "$rust_tests" +while IFS= read -r source; do + relative=${source#"$repo_root/rewatch/src/"} + destination="$rust_production/$relative" + test_destination="$rust_tests/$relative" + mkdir -p "$(dirname "$destination")" + mkdir -p "$(dirname "$test_destination")" + # Rust keeps unit tests beside production code. Stop at the first test-only + # module so the production comparison matches OCaml's separate test package. + awk '/^#\[cfg\(test\)\]/{exit} {print}' "$source" > "$destination" + awk 'found || /^#\[cfg\(test\)\]/{found = 1; print}' "$source" \ + > "$test_destination" +done < <(find "$repo_root/rewatch/src" -type f -name '*.rs' \ + ! -name telemetry.rs | sort) + +mapfile -t ocaml_production < <(find "$repo_root/rewatch-ocaml" -maxdepth 1 \ + -type f \( -name '*.ml' -o -name '*.mli' -o -name '*.c' \) | sort) +mapfile -t ocaml_unit_tests < <(find "$repo_root/tests/rewatch_ounit_tests" \ + -maxdepth 1 -type f -name '*.ml' | sort) +mapfile -t ocaml_focused_test_relative < <(git -C "$repo_root" ls-files \ + rewatch-ocaml/tests | sort) +ocaml_focused_tests=() +for relative in "${ocaml_focused_test_relative[@]}"; do + ocaml_focused_tests+=("$repo_root/$relative") +done +mapfile -t ocaml_benchmark_tooling < <(find "$repo_root/rewatch-ocaml/bench" \ + -maxdepth 1 -type f \( -name '*.sh' -o -name '*.js' \) | sort) + +count() { + local label=$1 + shift + local totals + totals=$("$cloc_command" --csv --quiet --skip-uniqueness "$@" \ + | awk -F, '$2 == "SUM" {print $3 "," $4 "," $5}') + printf '%-34s %8s %8s %8s\n' "$label" \ + "${totals%%,*}" "$(cut -d, -f2 <<< "$totals")" "${totals##*,}" +} + +printf '%-34s %8s %8s %8s\n' Scope Blank Comment Code +count "Rust production, no telemetry" "$rust_production" +count "Rust unit tests, no telemetry" "$rust_tests" +count "OCaml-port production" "${ocaml_production[@]}" +count "OCaml test code and fixtures" \ + --force-lang=ReScript,fixed --force-lang=ReScript,invalid \ + "${ocaml_unit_tests[@]}" "${ocaml_focused_tests[@]}" +count "OCaml benchmark tooling" "${ocaml_benchmark_tooling[@]}" + +largest() { + local label=$1 + shift + printf '\n%s (code lines):\n' "$label" + "$cloc_command" --by-file --csv --quiet --skip-uniqueness "$@" \ + | awk -F, -v root="$repo_root/" \ + '$1 != "language" && $1 != "SUM" { + sub("^" root, "", $2); + printf "%8d %s\n", $5, $2 + }' \ + | sort -nr \ + | head -10 +} + +largest "Largest OCaml production modules" "${ocaml_production[@]}" +largest "Largest OCaml test/tooling files" \ + --force-lang=ReScript,fixed --force-lang=ReScript,invalid \ + "${ocaml_unit_tests[@]}" "${ocaml_focused_tests[@]}" \ + "${ocaml_benchmark_tooling[@]}" + +printf '\ncloc version: %s\n' "$("$cloc_command" --version)" diff --git a/rewatch-ocaml/bench/watch_filesystem_audit.sh b/rewatch-ocaml/bench/watch_filesystem_audit.sh new file mode 100755 index 00000000000..239ef9eda10 --- /dev/null +++ b/rewatch-ocaml/bench/watch_filesystem_audit.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 RUST_REWATCH OCAML_REWATCH" >&2 + exit 2 +fi + +repo_root=$(cd "$(dirname "$0")/../.." && pwd) +rust_executable=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") +ocaml_executable=$(cd "$(dirname "$2")" && pwd)/$(basename "$2") +normalizer="$repo_root/rewatch-ocaml/bench/normalize_file_trace.js" + +for command in awk cat cp date find grep join mktemp node sed seq setsid sleep sort strace wc; do + command -v "$command" >/dev/null || { + echo "Missing required command: $command" >&2 + exit 2 + } +done +if [[ ! -x "$rust_executable" || ! -x "$ocaml_executable" ]]; then + echo "Both rewatch executables must exist and be executable." >&2 + exit 2 +fi + +if [[ -z ${RESCRIPT_BSC_EXE:-} || -z ${RESCRIPT_RUNTIME:-} ]]; then + eval "$(cd "$repo_root/rewatch/tests" && node ./get_bin_paths.js)" +fi +export RESCRIPT_BSC_EXE RESCRIPT_RUNTIME + +work_root=$(mktemp -d "${TMPDIR:-/tmp}/rewatch-watch-audit.XXXXXX") +background_pids="" +terminate_group() { + local pid=$1 + local reaped=false state + if ! kill -TERM -- "-$pid" 2>/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + fi + for _ in $(seq 1 100); do + if [[ $reaped == false && -r /proc/$pid/stat ]]; then + state=$(awk '{print $3}' "/proc/$pid/stat" 2>/dev/null || true) + if [[ $state == Z ]]; then + wait "$pid" 2>/dev/null || true + reaped=true + fi + fi + if ! kill -0 -- "-$pid" 2>/dev/null; then + if [[ $reaped == false ]]; then wait "$pid" 2>/dev/null || true; fi + return + fi + sleep 0.05 + done + kill -KILL -- "-$pid" 2>/dev/null || true + if [[ $reaped == false ]]; then kill -KILL "$pid" 2>/dev/null || true; fi + if [[ $reaped == false ]]; then wait "$pid" 2>/dev/null || true; fi +} +cleanup() { + trap - EXIT INT TERM + for pid in $background_pids; do + terminate_group "$pid" + done + if [[ ${KEEP_REWATCH_WATCH_AUDIT:-0} == 1 ]]; then + echo "Kept watch audit workdir: $work_root" >&2 + else + find "$work_root" -depth -delete + fi +} +trap cleanup EXIT INT TERM + +marker_script="$work_root/mark-build.mjs" +printf '%s\n' \ + 'import fs from "node:fs";' \ + 'fs.appendFileSync(process.env.REWATCH_WATCH_AUDIT_MARKER, `${Date.now()}\n`);' \ + >"$marker_script" + +wait_for_lines() { + local path=$1 expected=$2 + for _ in $(seq 1 400); do + if [[ -f "$path" ]] && [[ $(wc -l <"$path") -ge $expected ]]; then + return + fi + sleep 0.05 + done + echo "Timed out waiting for $expected build markers in $path" >&2 + exit 1 +} + +wait_for_watch_ready() { + local trace_prefix=$1 source_directory=$2 + for _ in $(seq 1 400); do + if grep -hF "inotify_add_watch" "$trace_prefix".* 2>/dev/null \ + | grep -Fq "\"$source_directory\""; then + return + fi + sleep 0.05 + done + echo "Timed out waiting for a native watch on $source_directory" >&2 + exit 1 +} + +wait_for_native_loop() { + local trace_pid=$1 + for _ in $(seq 1 400); do + local child task wait_channel + for child in $(cat "/proc/$trace_pid/task/$trace_pid/children" 2>/dev/null || true); do + for task in "/proc/$child"/task/*/wchan; do + [[ -r $task ]] || continue + wait_channel=$(cat "$task") + if [[ $wait_channel == *epoll* || $wait_channel == ep_poll* ]]; then + return + fi + done + done + sleep 0.05 + done + echo "Timed out waiting for the watcher event loop" >&2 + exit 1 +} + +trace_watch_edit() { + local implementation=$1 executable=$2 + local fixture="$work_root/$implementation" + local marker="$work_root/$implementation.marker" + local trace_prefix="$work_root/$implementation-edit.file" + local normalized="$work_root/$implementation-edit" + cp -R "$repo_root/rewatch-ocaml/tests/basic" "$fixture" + ( + cd "$fixture" + export REWATCH_WATCH_AUDIT_MARKER="$marker" + exec setsid strace -f -ff -qq -ttt -yy -s 4096 \ + -e trace=%file,getdents64 \ + -o "$trace_prefix" "$executable" watch \ + --after-build "node $marker_script" . \ + >"$normalized.stdout" 2>"$normalized.stderr" + ) & + local trace_pid=$! + background_pids="$background_pids $trace_pid" + wait_for_lines "$marker" 1 + wait_for_watch_ready "$trace_prefix" "$fixture/src" + # The after-build hook runs before the initial build has returned to the + # watcher. Wait for a subsequent blocking event-loop iteration so initial + # reconciliation work cannot be mistaken for retained-edit work. + wait_for_native_loop "$trace_pid" + local start_epoch end_epoch + start_epoch=$(date +%s.%N) + printf 'let answer = A.value + 2\n' >"$fixture/src/B.res" + wait_for_lines "$marker" 2 + end_epoch=$(date +%s.%N) + rm -f "$fixture/lib/watch.lock" + wait "$trace_pid" + background_pids=${background_pids% $trace_pid} + node "$normalizer" "$trace_prefix" "$fixture" "$normalized" \ + "$start_epoch" "$end_epoch" +} + +trace_watch_edit rust "$rust_executable" +trace_watch_edit ocaml "$ocaml_executable" + +echo "watch single-edit project filesystem categories (Rust / OCaml)" +join -a 1 -a 2 -e 0 -o 0,1.2,2.2 \ + "$work_root/rust-edit.categories.tsv" \ + "$work_root/ocaml-edit.categories.tsv" +for implementation in rust ocaml; do + echo + echo "$implementation most repeated project path operations" + sort -t $'\t' -k1,1nr "$work_root/$implementation-edit.paths.tsv" \ + | sed -n '1,20p' +done + +echo +echo "This audit covers one edit inside an already-running watcher. Inspect" +echo "retained traces for per-process attribution and exact repeated paths." diff --git a/rewatch-ocaml/bench/watch_performance_gate.sh b/rewatch-ocaml/bench/watch_performance_gate.sh new file mode 100755 index 00000000000..b21f38c5307 --- /dev/null +++ b/rewatch-ocaml/bench/watch_performance_gate.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 || $# -gt 3 ]]; then + echo "Usage: $0 RUST_REWATCH OCAML_REWATCH [RUNS]" >&2 + exit 2 +fi + +repo_root=$(cd "$(dirname "$0")/../.." && pwd) +rust_executable=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") +ocaml_executable=$(cd "$(dirname "$2")" && pwd)/$(basename "$2") +runs=${3:-7} +threshold_percent=${REWATCH_WATCH_PERFORMANCE_THRESHOLD_PERCENT:-150} + +if ((runs < 5 || runs % 2 == 0)); then + echo "RUNS must be an odd number of at least five." >&2 + exit 2 +fi +for command in awk cat cmp cp date find grep mktemp node sed seq setsid sleep sort tail wc; do + command -v "$command" >/dev/null || { + echo "Missing required command: $command" >&2 + exit 2 + } +done +if [[ ! -x $rust_executable || ! -x $ocaml_executable ]]; then + echo "Both rewatch executables must exist and be executable." >&2 + exit 2 +fi +if [[ ! -d /proc/self/fd ]]; then + echo "The retained-watch performance gate requires Linux /proc." >&2 + exit 2 +fi + +if [[ -z ${RESCRIPT_BSC_EXE:-} || -z ${RESCRIPT_RUNTIME:-} ]]; then + eval "$(cd "$repo_root/rewatch/tests" && node ./get_bin_paths.js)" +fi +real_bsc=$RESCRIPT_BSC_EXE +runtime=$RESCRIPT_RUNTIME +counting_bsc="$repo_root/_build/default/tests/rewatch_ounit_tests/rewatch_bsc_test_proxy.exe" + +work_root=$(mktemp -d "${TMPDIR:-/tmp}/rewatch-watch-performance.XXXXXX") +declare -A pids=() +terminate_group() { + local pid=$1 + local reaped=false state + if ! kill -TERM -- "-$pid" 2>/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + fi + for _ in $(seq 1 100); do + if [[ $reaped == false && -r /proc/$pid/stat ]]; then + state=$(awk '{print $3}' "/proc/$pid/stat" 2>/dev/null || true) + if [[ $state == Z ]]; then + wait "$pid" 2>/dev/null || true + reaped=true + fi + fi + if ! kill -0 -- "-$pid" 2>/dev/null; then + if [[ $reaped == false ]]; then wait "$pid" 2>/dev/null || true; fi + return + fi + sleep 0.05 + done + kill -KILL -- "-$pid" 2>/dev/null || true + if [[ $reaped == false ]]; then kill -KILL "$pid" 2>/dev/null || true; fi + if [[ $reaped == false ]]; then wait "$pid" 2>/dev/null || true; fi +} +cleanup() { + trap - EXIT INT TERM + local implementation pid + for implementation in "${!pids[@]}"; do + pid=${pids[$implementation]} + terminate_group "$pid" + done + if [[ ${KEEP_REWATCH_WATCH_PERFORMANCE:-0} == 1 ]]; then + echo "Kept watch performance workdir: $work_root" >&2 + else + find "$work_root" -depth -delete + fi +} +trap cleanup EXIT INT TERM + +marker_script="$work_root/mark-build.mjs" +printf '%s\n' \ + 'import fs from "node:fs";' \ + 'fs.appendFileSync(process.env.REWATCH_WATCH_MARKER, `${Date.now()}\n`);' \ + >"$marker_script" + +wait_for_lines() { + local path=$1 expected=$2 + for _ in $(seq 1 400); do + if [[ -f $path ]] && [[ $(wc -l <"$path") -ge $expected ]]; then return; fi + sleep 0.05 + done + echo "Timed out waiting for $expected lines in $path" >&2 + exit 1 +} + +wait_for_text_count() { + local path=$1 text=$2 expected=$3 + for _ in $(seq 1 400); do + if [[ -f $path ]] && \ + [[ $(grep -cF "$text" "$path" 2>/dev/null || true) -ge $expected ]]; then + return + fi + sleep 0.05 + done + echo "Timed out waiting for occurrence $expected of '$text' in $path" >&2 + exit 1 +} + +wait_for_idle() { + local pid=$1 + for _ in $(seq 1 400); do + if [[ ! -d /proc/$pid ]]; then + echo "Watcher $pid exited before becoming idle." >&2 + exit 1 + fi + local children state + children=$(cat "/proc/$pid/task/$pid/children" 2>/dev/null || true) + state=$(awk '{print $3}' "/proc/$pid/stat") + if [[ -z $children && $state == S ]]; then return; fi + sleep 0.01 + done + echo "Timed out waiting for watcher $pid to become idle." >&2 + exit 1 +} + +resource_value() { + local pid=$1 kind=$2 + case $kind in + fd) find "/proc/$pid/fd" -mindepth 1 -maxdepth 1 | wc -l ;; + tasks) find "/proc/$pid/task" -mindepth 1 -maxdepth 1 -type d | wc -l ;; + rss) awk '/^VmRSS:/ {print $2}' "/proc/$pid/status" ;; + esac +} + +start_watcher() { + local implementation=$1 executable=$2 fixture + fixture="$work_root/$implementation" + cp -R "$repo_root/rewatch-ocaml/tests/basic" "$fixture" + : >"$work_root/$implementation.bsc" + setsid env \ + RESCRIPT_BSC_EXE="$counting_bsc" \ + REWATCH_BSC_PROXY_MODE=counting \ + RESCRIPT_RUNTIME="$runtime" \ + REWATCH_REAL_BSC="$real_bsc" \ + REWATCH_BSC_CALL_LOG="$work_root/$implementation.bsc" \ + REWATCH_WATCH_MARKER="$work_root/$implementation.marker" \ + "$executable" watch --after-build "node $marker_script" "$fixture" \ + >"$work_root/$implementation.stdout" \ + 2>"$work_root/$implementation.stderr" & + pids[$implementation]=$! + wait_for_lines "$work_root/$implementation.marker" 1 + wait_for_text_count "$work_root/$implementation.stdout" \ + "Finished initial compilation" 1 + wait_for_idle "${pids[$implementation]}" +} + +start_watcher rust "$rust_executable" +start_watcher ocaml "$ocaml_executable" + +# One unmeasured edit pays any first-incremental initialization cost before the +# retained-state samples begin. +for implementation in rust ocaml; do + printf 'let answer = A.value + 1\n// warm retained edit\n' \ + >"$work_root/$implementation/src/B.res" + wait_for_lines "$work_root/$implementation.marker" 2 + wait_for_text_count "$work_root/$implementation.stdout" \ + "Finished incremental compilation" 1 + wait_for_idle "${pids[$implementation]}" + : >"$work_root/$implementation.bsc" +done + +declare -A baseline_fd baseline_tasks baseline_rss max_fd max_tasks max_rss +for implementation in rust ocaml; do + pid=${pids[$implementation]} + baseline_fd[$implementation]=$(resource_value "$pid" fd) + baseline_tasks[$implementation]=$(resource_value "$pid" tasks) + baseline_rss[$implementation]=$(resource_value "$pid" rss) + max_fd[$implementation]=${baseline_fd[$implementation]} + max_tasks[$implementation]=${baseline_tasks[$implementation]} + max_rss[$implementation]=${baseline_rss[$implementation]} + : >"$work_root/$implementation.latencies" +done + +measure_edit() { + local implementation=$1 round=$2 expected=$((round + 2)) + local started finished latency pid value + started=$(date +%s%3N) + printf 'let answer = A.value + 1\n// retained edit %d\n' "$round" \ + >"$work_root/$implementation/src/B.res" + wait_for_lines "$work_root/$implementation.marker" "$expected" + finished=$(tail -n 1 "$work_root/$implementation.marker") + latency=$((finished - started)) + printf '%d\n' "$latency" >>"$work_root/$implementation.latencies" + wait_for_text_count "$work_root/$implementation.stdout" \ + "Finished incremental compilation" "$((round + 1))" + wait_for_idle "${pids[$implementation]}" + pid=${pids[$implementation]} + for kind in fd tasks rss; do + value=$(resource_value "$pid" "$kind") + case $kind in + fd) if ((value > max_fd[$implementation])); then max_fd[$implementation]=$value; fi ;; + tasks) if ((value > max_tasks[$implementation])); then max_tasks[$implementation]=$value; fi ;; + rss) if ((value > max_rss[$implementation])); then max_rss[$implementation]=$value; fi ;; + esac + done +} + +for round in $(seq 1 "$runs"); do + if ((round % 2 == 1)); then + measure_edit rust "$round" + measure_edit ocaml "$round" + else + measure_edit ocaml "$round" + measure_edit rust "$round" + fi + if ! cmp -s "$work_root/rust/src/B.mjs" "$work_root/ocaml/src/B.mjs"; then + echo "Generated output differs after retained edit $round." >&2 + exit 1 + fi +done + +median() { + sort -n "$1" | sed -n "$((runs / 2 + 1))p" +} +rust_median=$(median "$work_root/rust.latencies") +ocaml_median=$(median "$work_root/ocaml.latencies") + +normalize_calls() { + local implementation=$1 + sed "s#$work_root/$implementation##g" \ + "$work_root/$implementation.bsc" >"$work_root/$implementation.bsc.normalized" +} +normalize_calls rust +normalize_calls ocaml +if ! cmp -s "$work_root/rust.bsc.normalized" \ + "$work_root/ocaml.bsc.normalized"; then + echo "Rust and OCaml retained edits performed different compiler work." >&2 + exit 1 +fi +for implementation in rust ocaml; do + parse_count=$(grep -cF -- '-bs-ast' "$work_root/$implementation.bsc" || true) + total_count=$(wc -l <"$work_root/$implementation.bsc") + if ((parse_count != runs || total_count != runs * 2)); then + printf '%s retained work was %d parser / %d total calls; expected %d / %d.\n' \ + "$implementation" "$parse_count" "$total_count" "$runs" "$((runs * 2))" >&2 + exit 1 + fi +done + +for implementation in rust ocaml; do + pid=${pids[$implementation]} + final_fd=$(resource_value "$pid" fd) + final_tasks=$(resource_value "$pid" tasks) + final_rss=$(resource_value "$pid" rss) + if ((max_fd[$implementation] > baseline_fd[$implementation] + 4 || + max_tasks[$implementation] > baseline_tasks[$implementation] + 2 || + max_rss[$implementation] > baseline_rss[$implementation] + 16384)); then + echo "$implementation watcher resource growth exceeded the retained gate." >&2 + exit 1 + fi + printf '%-5s resources: fd %d→%d (max %d), tasks %d→%d (max %d), RSS %d→%d KiB (max %d)\n' \ + "$implementation" "${baseline_fd[$implementation]}" "$final_fd" \ + "${max_fd[$implementation]}" "${baseline_tasks[$implementation]}" \ + "$final_tasks" "${max_tasks[$implementation]}" \ + "${baseline_rss[$implementation]}" "$final_rss" "${max_rss[$implementation]}" +done + +if ((ocaml_median * 100 > rust_median * threshold_percent)); then + printf 'OCaml retained-watch median %d ms exceeds %d%% of Rust median %d ms.\n' \ + "$ocaml_median" "$threshold_percent" "$rust_median" >&2 + exit 1 +fi + +printf 'retained-watch median: Rust %d ms, OCaml %d ms (limit %d%%)\n' \ + "$rust_median" "$ocaml_median" "$threshold_percent" +printf 'compiler work: %d parser and %d compiler calls per implementation\n' \ + "$runs" "$runs" + +for implementation in rust ocaml; do + rm -f "$work_root/$implementation/lib/watch.lock" + wait "${pids[$implementation]}" + unset "pids[$implementation]" +done + +echo "Retained-watch performance, work, output, and resource gates passed" diff --git a/rewatch-ocaml/build.ml b/rewatch-ocaml/build.ml new file mode 100644 index 00000000000..900afee335f --- /dev/null +++ b/rewatch-ocaml/build.ml @@ -0,0 +1,564 @@ +exception Error = Project_context.Error +exception Package_error = Project_context.Package_error +exception Build_failure = Compiler_scheduler.Build_failure +exception Parse_failure of string +exception Reported_failure of string +exception Full_rebuild_required + +type retained_build = { + root_config: Config.t; + build_lock_root: string; + session: Build_session.t; +} + +type attempt_request = + | One_shot_attempt + | Initial_watch_attempt + | Full_watch_attempt + | Retained_watch_attempt of { + previous: retained_build; + changes: Watcher.change list; + } + +(* Build kind controls which persistent markers and diagnostics may be reused. + Keeping all four states explicit prevents an initial watch build from being + mistaken for either a disposable command or a retained incremental edit. *) +type compilation_kind = Build_attempt.compilation_kind = + | One_shot + | Initial_watch + | Incremental_watch + | Full_watch + +let compilation_kind = function + | One_shot_attempt -> One_shot + | Initial_watch_attempt -> Initial_watch + | Full_watch_attempt -> Full_watch + | Retained_watch_attempt _ -> Incremental_watch + +let previous_build = function + | Retained_watch_attempt {previous; changes = _} -> Some previous + | One_shot_attempt | Initial_watch_attempt | Full_watch_attempt -> None + +type incremental_source = { + package: Package_plan.t; + source: Build_session.source_reference; +} + +let run_scheduled_modules (attempt : Build_attempt.t) + (prepared : Build_session.prepared) ~compile_step ~namespace_count = + Compiler_scheduler.run ~poll:attempt.process_poll + ~warning_state:(Build_session.warning_state attempt.session) + ~compile_assets:prepared.compile_assets ~build_state:prepared.build_state + ~candidates:(Build_attempt.take_compile_candidates attempt) + ~mark_compiled:(fun () -> attempt.compiled <- attempt.compiled + 1) + ~mark_had_warnings:(fun () -> attempt.had_warnings <- true) + ~progress:attempt.progress ~compile_step ~namespace_count + ~verbosity:attempt.verbosity + +let run_namespace_jobs (attempt : Build_attempt.t) = + let jobs = Build_attempt.take_namespace_jobs attempt in + let started_at = Unix.gettimeofday () in + Fun.protect + ~finally:(fun () -> + attempt.parse_seconds <- + attempt.parse_seconds +. (Unix.gettimeofday () -. started_at)) + (fun () -> + let results = + Process.run_parallel ?poll:attempt.process_poll + (List.map (fun job -> job.Build_attempt.job) jobs) + in + List.iter2 + (fun job result -> job.Build_attempt.finish result) + jobs results); + List.length jobs + +let write_build_ninja (attempt : Build_attempt.t) = + (* This empty file is a cache-invalidation marker consumed by editor tooling, + not a serialized build plan. Only commands that reconstruct the project + graph call this function. *) + Build_session.iter_package_plans attempt.session (fun _ package -> + let path = Filename.concat package.Package_plan.build_dir "build.ninja" in + let channel = open_out_bin path in + close_out channel) + +let incremental_sources (previous : retained_build) changes = + (* Reusing the graph is safe only for modifications of already-known source + paths. Additions, removals, and unknown paths can change module identity or + package topology, so their caller must reconstruct the build instead. *) + let included = Hashtbl.create (List.length changes) in + let sources = ref [] in + let add normalized_path = + if not (Hashtbl.mem included normalized_path) then ( + Hashtbl.add included normalized_path (); + match + Build_session.find_source_reference previous.session normalized_path + with + | Some source -> + let package = + match + Build_session.find_package_plan previous.session source.package_root + with + | Some package -> package + | None -> raise Full_rebuild_required + in + sources := {package; source} :: !sources + | None -> raise Full_rebuild_required) + in + List.iter + (fun (change : Watcher.change) -> + match change.kind with + | Watcher.Added | Watcher.Removed -> raise Full_rebuild_required + | Watcher.Modified -> + add (Platform.normalize_path_for_comparison change.path)) + changes; + Build_session.pending_parse_paths previous.session + |> List.sort String.compare |> List.iter add; + List.rev !sources + +let prepare_incremental previous changes (attempt : Build_attempt.t) + (prepared : Build_session.prepared) = + (* A retained edit reparses only the reported paths, then replaces the + affected modules' dependency edges in memory. This keeps the long-lived + graph coherent without rediscovering the package tree. *) + let sources = incremental_sources previous changes in + let bsc = prepared.compiler_context.bsc_path in + let started_at = Unix.gettimeofday () in + List.iter + (fun source -> + Build_session.mark_parse_pending attempt.session + (Platform.normalize_path_for_comparison source.source.absolute_path); + let key = + Source.compiler_basename source.package.compile_config + source.source.module_.Source.name + in + (Build_state.find_exn prepared.build_state key).compile_dirty <- true) + sources; + sources + |> List.map (fun source -> + Source.compiler_basename source.package.compile_config + source.source.module_.Source.name) + |> List.sort_uniq String.compare + |> List.iter (fun name -> + Output.debug ~verbosity:attempt.verbosity + ("Generating AST for module: " ^ name)); + let parse_completed = + Output.Progress.start_grouped attempt.progress ~step:"1/2" + ~symbol:Platform.parse_symbol ~label:"Parsing" + (List.map + (fun source -> + source.package.root ^ "\000" ^ source.source.module_.Source.name) + sources) + in + let results = + Process.run_parallel_map ?poll:attempt.process_poll + ~on_complete:parse_completed sources ~job:(fun source -> + Compiler_process.parse_job ~bsc ~build_dir:source.package.build_dir + ~config:source.package.compile_config source.source.relative_path) + in + let affected_modules = Hashtbl.create (List.length sources) in + let dependency_updates = ref [] in + List.iter2 + (fun source result -> + Hashtbl.replace attempt.preliminary_parses source.source.absolute_path + (Build_attempt.preliminary_parse result); + (try + let modified = (Unix.stat source.source.absolute_path).Unix.st_mtime in + Hashtbl.replace source.package.source_mtimes + source.source.relative_path modified + with Unix.Unix_error _ | Sys_error _ -> raise Full_rebuild_required); + let key = + Source.compiler_basename source.package.compile_config + source.source.module_.Source.name + in + let parse_failed = not (Process.succeeded result) in + let parse_failed = + match Hashtbl.find_opt affected_modules key with + | Some (_, _, previous_failed) -> previous_failed || parse_failed + | None -> parse_failed + in + Hashtbl.replace affected_modules key + (source.package, source.source.module_, parse_failed)) + sources results; + Hashtbl.iter + (fun key (package, module_, changed_parse_failed) -> + if not changed_parse_failed then + let dependencies path = + Compiler_process.ast_dependencies + ~build_dir:package.Package_plan.build_dir (Source.ast_path path) + in + let raw_dependencies = + List.sort_uniq String.compare + (dependencies module_.Source.implementation + @ + match module_.Source.interface with + | None -> [] + | Some path -> dependencies path) + in + let node = + match Build_session.find_global_module attempt.session key with + | Some node -> node + | None -> raise Full_rebuild_required + in + if node.raw_dependencies <> raw_dependencies then + dependency_updates := + (key, node, raw_dependencies) :: !dependency_updates) + affected_modules; + attempt.parse_seconds <- Unix.gettimeofday () -. started_at; + if !dependency_updates <> [] then ( + Build_session.invalidate_graph_cycle attempt.session; + List.iter + (fun (key, node, raw_dependencies) -> + node.Module_graph.raw_dependencies <- raw_dependencies; + Build_state.set_dependencies prepared.build_state ~key + (Module_graph.resolved_dependencies + ~find_module:(Build_session.find_global_module attempt.session) + ~find_namespace_maps: + (Build_session.find_namespace_maps attempt.session) + node)) + !dependency_updates; + let cycle = + Module_graph.find_cycle + (Build_session.global_module_values attempt.session) + (Build_session.namespace_map_values attempt.session) + prepared.build_state + in + Build_session.set_graph_cycle attempt.session cycle; + cycle) + else + match Build_session.graph_cycle attempt.session with + | Build_session.Known_cycle cycle -> cycle + | Build_session.Unknown_cycle -> + let cycle = + Module_graph.find_cycle + (Build_session.global_module_values attempt.session) + (Build_session.namespace_map_values attempt.session) + prepared.build_state + in + Build_session.set_graph_cycle attempt.session cycle; + cycle + +let run_with_warning_state ~poll ~warning_state ~request ~no_timing ~verbosity + ~folder ~prod ~features ~warn_error ~after_build ~filter ~on_state = + let compilation_kind = compilation_kind request in + let started_at = Unix.gettimeofday () in + let interactive = Unix.isatty Unix.stdout && Unix.isatty Unix.stderr in + let show_progress = verbosity >= 0 in + let colors = Output.colors_enabled ~interactive in + let progress = + Output.Progress.create ~enabled:(interactive && show_progress) ~color:colors + in + let poll () = + poll (); + Output.Progress.tick progress + in + let process_poll = Some poll in + let watch = compilation_kind <> One_shot in + let is_rebuild = compilation_kind = Incremental_watch in + let should_write_build_ninja = + match compilation_kind with + | One_shot | Full_watch -> true + | Initial_watch | Incremental_watch -> false + in + let root = Project_context.canonical_project_root folder in + let root_config = + match previous_build request with + | Some previous -> previous.root_config + | None -> Config.load_root root + in + let build_lock_root = Project_context.workspace_lock_root_for root_config in + Output.debug ~verbosity + (Printf.sprintf "Created project context Single project: %S at %S for %S" + root_config.name root_config.path root_config.root); + let visited = Hashtbl.create 32 in + let attempt : Build_attempt.t = + match previous_build request with + | Some previous -> + Build_attempt.create_retained ~session:previous.session ~process_poll + ~progress ~verbosity + | None -> + Build_attempt.create_full ~warning_state ~process_poll ~progress + ~verbosity + in + let parse_messages () = List.rev attempt.parse_messages in + let parse_output messages = + messages + |> List.map (function + | Build_attempt.Parse_warning output | Build_attempt.Parse_error output + -> output) + |> String.concat "" + in + let parse_failed = Build_attempt.has_parse_error in + (* A watch build must retain the attempted state even when later parsing or + compilation fails, because its successful ASTs and artifact inventory are + needed to recover incrementally on the next edit. Publish ownership before + any fallible phase starts. *) + on_state {root_config; build_lock_root; session = attempt.session}; + let report = + Build_report.create ~started_at ~interactive ~show_progress ~colors + ~no_timing ~compilation_kind ~attempt + in + let build_ninja_written = ref false in + let write_build_ninja_once () = + if should_write_build_ninja && not !build_ninja_written then ( + write_build_ninja attempt; + build_ninja_written := true) + in + let phase_seconds seconds = if no_timing then 0. else seconds in + let parse_step = if is_rebuild then "1/2" else "2/3" in + let compile_step = if is_rebuild then "2/2" else "3/3" in + let report_failure ~compile_seconds output = + Build_attempt.finalize_logs attempt; + if attempt.freshness_mode = Build_attempt.Initialize_freshness then + Source_dirs.write_build ~root_config attempt.session; + Build_report.report report ~success:false ~compile_seconds; + prerr_string output; + prerr_newline (); + Build_attempt.cleanup_artifacts attempt; + write_build_ninja_once (); + raise + (Reported_failure + ("Incremental build failed. Error: \027[2K\r Failed to Compile. " + ^ "See Errors Above")) + in + let report_parse_failure output = + Build_attempt.finalize_logs attempt; + Build_report.report_parse_failure report ~output; + Build_attempt.cleanup_artifacts attempt; + write_build_ninja_once (); + raise + (Reported_failure + "Incremental build failed. Error: \027[2K\r Could not parse Source \ + Files") + in + let format_cycle cycle (by_key : (string, Module_graph.cycle_node) Hashtbl.t) + = + let format_node name = + match Hashtbl.find_opt by_key name with + | None -> name + | Some node -> ( + match node.source_path with + | Some source_path -> + let absolute = Filename.concat node.package_root source_path in + Printf.sprintf "%s (%s)" node.display_name + (Project_context.relative_or_absolute ~root:root_config.root + absolute) + | None -> + Printf.sprintf "%s (%s namespace map)" node.display_name + (Project_context.relative_or_absolute ~root:root_config.root + node.package_root)) + in + "\nCan't continue... Found a circular dependency in your code:\n" + ^ (cycle |> List.map format_node |> String.concat "\n → ") + ^ "\n\ + Possible solutions:\n\ + - Extract shared code into a new module both depend on.\n" + in + let execute ~release_build_lock = + poll (); + let prepared, cycle = + match request with + | Retained_watch_attempt {previous; changes} -> ( + match Build_session.prepared attempt.session with + | Some prepared -> + (prepared, prepare_incremental previous changes attempt prepared) + | None -> raise Full_rebuild_required) + | One_shot_attempt | Initial_watch_attempt | Full_watch_attempt -> + let preparation = + Build_preparation.run ~root_config ~prod ~features ~warn_error ~filter + ~watch ~attempt ~parse_step ~on_cleanup:(fun seconds -> + if interactive && show_progress && not is_rebuild then ( + if attempt.compiler_cleaned then + print_endline + (Output.compiler_cleanup_message ~color:colors ~step:"1/3"); + print_endline + (Output.cleanup_message ~color:colors ~step:"1/3" + ~cleaned:attempt.cleaned ~total:attempt.previous_asts + ~seconds:(phase_seconds seconds)))) + in + (preparation.prepared, preparation.cycle) + in + poll (); + if attempt.compiler_cleaned && show_progress && not interactive then + print_endline "Cleaned previous build due to compiler update"; + Option.iter + (fun (cycle_info : Module_graph.cycle_info) -> + List.iter + (fun name -> Hashtbl.replace attempt.blocked_modules name ()) + cycle_info.blocked) + cycle; + let root_package = + match Build_session.find_package_plan attempt.session root with + | Some package -> package + | None -> raise (Error ("Package graph was not prepared for " ^ root)) + in + Package_build.prepare_tree ~seen:visited ~package:root_package ~prepared + ~watch ~attempt; + Build_session.mark_freshness_initialized attempt.session; + let parse_messages = parse_messages () in + let parse_output = parse_output parse_messages in + if parse_failed parse_messages then raise (Parse_failure parse_output); + poll (); + let namespace_count = + try run_namespace_jobs attempt + with Build_failure output -> + raise (Parse_failure (parse_output ^ output)) + in + Output.Progress.finish progress; + if interactive && show_progress then + print_endline + (Output.parsing_message ~color:colors ~step:parse_step + ~count:attempt.parsed + ~seconds:(phase_seconds attempt.parse_seconds)); + flush stdout; + prerr_string parse_output; + flush stderr; + let compile_started = Unix.gettimeofday () in + let compile_failure = + try + run_scheduled_modules attempt prepared ~compile_step ~namespace_count; + None + with Build_failure output -> Some output + in + Output.Progress.finish progress; + let compile_seconds = + phase_seconds (Unix.gettimeofday () -. compile_started) + in + match (compile_failure, cycle) with + | Some output, _ -> report_failure ~compile_seconds output + | None, Some cycle_info -> + let output = format_cycle cycle_info.cycle cycle_info.nodes_by_key in + cycle_info.cycle + |> List.filter_map (Hashtbl.find_opt cycle_info.nodes_by_key) + |> List.map (fun (node : Module_graph.cycle_node) -> node.package_root) + |> List.sort_uniq String.compare + |> List.iter (fun package_root -> Compiler_log.append package_root output); + report_failure ~compile_seconds output + | None, None -> + Build_attempt.finalize_logs attempt; + if attempt.freshness_mode = Build_attempt.Initialize_freshness then + Source_dirs.write_build ~root_config attempt.session; + let diagnostics = + Build_report.print_success_details report ~compile_seconds + in + let context = prepared.compiler_context in + Build_session.publish_compiler_info attempt.session (fun package -> + let package_context = + Compiler_info.for_package context + ~build_root:package.Package_plan.build_owner + package.compile_config + in + Compiler_info.write_package package_context package.config); + if compilation_kind = One_shot then + Build_report.report_completion report diagnostics; + Build_attempt.cleanup_artifacts attempt; + write_build_ninja_once (); + release_build_lock (); + Option.iter + (fun command -> After_build.run ?poll:process_poll ~root command) + after_build; + if compilation_kind <> One_shot then + Build_report.report_completion report diagnostics + in + Build_lock.with_build ~poll build_lock_root + (fun ~release:release_build_lock -> + Build_attempt.protect attempt (fun () -> + try execute ~release_build_lock with + | Build_failure output -> report_failure ~compile_seconds:0. output + | Parse_failure output -> report_parse_failure output)); + {root_config; build_lock_root; session = attempt.session} + +let run ~poll ~verbosity ~folder ~prod ~features ~warn_error ~after_build + ~filter ~no_timing = + try + run_with_warning_state ~warning_state:(Warning_state.create ()) ~poll + ~request:One_shot_attempt ~no_timing ~verbosity ~folder ~prod ~features + ~warn_error ~after_build ~filter ~on_state:(fun _ -> ()) + |> ignore + with Reported_failure message -> raise (Error message) + +let remove_compile_warning_freshness warning_state = + Warning_state.entries warning_state + |> List.iter (fun (entry : Warning_state.entry) -> + let implementation = + if Filename.check_suffix entry.path ".resi" then + Filename.chop_suffix entry.path "i" + else entry.path + in + [implementation; implementation ^ "i"] + |> List.iter (fun source -> + File_util.remove_file + (Build_artifacts.published_ast_path + ~ocaml_dir:(Build_artifacts.lib_path entry.package_root "ocaml") + source))) + +let watch ~verbosity ~folder ~prod ~features ~warn_error ~after_build ~filter + ~clear_screen = + let root = Project_context.canonical_project_root folder in + let warning_state = Warning_state.create () in + let initial_build = ref true in + let retained = ref None in + let force_full_rebuild = ref false in + let build ~poll ~changes = + let is_initial = !initial_build in + initial_build := false; + try + let run request = + let compilation_kind = compilation_kind request in + let attempted = ref None in + try + run_with_warning_state ~poll ~warning_state ~request ~no_timing:false + ~verbosity ~folder ~prod ~features ~warn_error ~after_build ~filter + ~on_state:(fun state -> attempted := Some state) + with exn -> + (* Failed initial and incremental attempts still own useful parsed + state. Full reconstruction failures do not, because their graph may + be only partially discovered. *) + (match (compilation_kind, !attempted) with + | (Initial_watch | Incremental_watch), Some state -> + retained := Some state + | Full_watch, Some state + when Option.is_some (Build_session.prepared state.session) -> + retained := Some state; + force_full_rebuild := false + | (One_shot | Full_watch), _ | _, None -> ()); + raise exn + in + let next = + match (!retained, changes, !force_full_rebuild) with + | Some previous, Some changes, false -> ( + try run (Retained_watch_attempt {previous; changes}) + with Full_rebuild_required -> + force_full_rebuild := true; + run Full_watch_attempt) + | Some _, _, true -> run Full_watch_attempt + | None, _, _ -> + run (if is_initial then Initial_watch_attempt else Full_watch_attempt) + | Some _, None, false -> run Full_watch_attempt + in + retained := Some next; + force_full_rebuild := false; + Watcher.Succeeded + with + | Reported_failure _ -> Watcher.Failed + | Package_error message + | Error message + | Config.Error message + | Source.Error message + | Process.Error message -> + prerr_endline message; + Watcher.Failed + | (Sys_error _ as exn) | (Unix.Unix_error _ as exn) -> + prerr_endline (Printexc.to_string exn); + Watcher.Failed + in + Fun.protect + (fun () -> + Watcher.run ~root ~prod ~features ~filter ~clear_screen + ~show_progress:(verbosity >= 0) ~verbosity ~build) + ~finally:(fun () -> + if Warning_state.entries warning_state <> [] then + match !retained with + | Some state -> + Build_lock.with_build state.build_lock_root (fun ~release:_ -> + remove_compile_warning_freshness warning_state) + | None -> ()) diff --git a/rewatch-ocaml/build.mli b/rewatch-ocaml/build.mli new file mode 100644 index 00000000000..d851e6c82a4 --- /dev/null +++ b/rewatch-ocaml/build.mli @@ -0,0 +1,22 @@ +val run : + poll:(unit -> unit) -> + verbosity:int -> + folder:string -> + prod:bool -> + features:string list option -> + warn_error:string option -> + after_build:string option -> + filter:Source_filter.t option -> + no_timing:bool -> + unit + +val watch : + verbosity:int -> + folder:string -> + prod:bool -> + features:string list option -> + warn_error:string option -> + after_build:string option -> + filter:Source_filter.t option -> + clear_screen:bool -> + unit diff --git a/rewatch-ocaml/build_artifacts.ml b/rewatch-ocaml/build_artifacts.ml new file mode 100644 index 00000000000..9c309eba8b8 --- /dev/null +++ b/rewatch-ocaml/build_artifacts.ml @@ -0,0 +1,370 @@ +let lib_path root directory = File_util.path_of_parts root ["lib"; directory] + +let relative_output_directory path (spec : Config.package_spec) = + let directory = Filename.dirname path in + if spec.in_source then directory + else + Filename.concat + (match spec.module_format with + | Config.Esmodule -> lib_path "" "es6" + | Config.Commonjs -> lib_path "" "js") + directory + +let generated_js_path (config : Config.t) path (spec : Config.package_spec) = + let output_dir = relative_output_directory path spec in + Filename.concat config.root + (Filename.concat output_dir + (Filename.remove_extension (Filename.basename path) + ^ Config.package_spec_suffix config spec)) + +let published_ast_path ~ocaml_dir source = + Filename.concat ocaml_dir (Filename.basename (Source.ast_path source)) + +let generated_build_js_path ~build_dir (config : Config.t) path + (spec : Config.package_spec) = + Filename.concat build_dir + (Filename.remove_extension path ^ Config.package_spec_suffix config spec) + +let remove_public_outputs (config : Config.t) implementation_paths = + List.iter + (fun implementation -> + List.iter + (fun spec -> + let output = generated_js_path config implementation spec in + File_util.remove_file output; + File_util.remove_file (output ^ ".map")) + config.package_specs) + implementation_paths + +let generated_output_suffixes = + [ + ".bs.mjs"; + ".bs.cjs"; + ".bs.js"; + ".res.mjs"; + ".res.cjs"; + ".res.js"; + ".mjs"; + ".cjs"; + ".js"; + ] + +let generated_output_details_for_suffixes suffixes path = + let output_path = + if Filename.check_suffix path ".map" then Filename.chop_suffix path ".map" + else path + in + suffixes + |> List.find_map (fun suffix -> + if Filename.check_suffix output_path suffix then + Some + ( ( Filename.basename output_path |> fun basename -> + Filename.chop_suffix basename suffix ), + suffix, + output_path ) + else None) + +type cleanup_result = { + removed_modules: string list; + previous_ast_count: int; + present_public_outputs: (string, unit) Hashtbl.t; +} + +let cleanup_stale ?ocaml_files ?ast_sources ?source_files ?present_source_files + ~on_removed_module ~on_deferred_artifact ~root ~ocaml_dir ~is_local + (config : Config.t) modules = + let build_dir = lib_path root "bs" in + (* Keep one inventory of each artifact tree to avoid repeating directory and + metadata work during every cleanup phase. Paths removed below can safely + remain in the inventory: later phases only classify their names or call + the idempotent File_util.remove_file. *) + let ocaml_files = + match ocaml_files with + | Some files -> files + | None -> File_util.files_under ocaml_dir + in + (* Published ASTs contain the absolute source path used to create them, which + is enough to address their working artifacts without scanning for them. + Keep the recursive walk lazy for malformed or legacy ASTs that cannot be + mapped; normal unchanged builds must not inventory the whole lib/bs tree. *) + let ast_sources = Option.value ast_sources ~default:[] in + let fallback_build_files_by_basename = + lazy + (let by_basename = Hashtbl.create 32 in + File_util.files_under build_dir + |> List.iter (fun path -> + let basename = Filename.basename path in + let paths = + Hashtbl.find_opt by_basename basename |> Option.value ~default:[] + in + Hashtbl.replace by_basename basename (path :: paths)); + by_basename) + in + let source_files = + match source_files with + | Some files -> files + | None -> + config.sources + |> List.concat_map (fun source -> + File_util.files_under (Filename.concat root source.Config.dir)) + in + let present_source_files = + Option.value present_source_files ~default:source_files + in + let output_files = + [lib_path "" "es6"; lib_path "" "js"] + |> List.map (fun directory -> + let output_dir = Filename.concat root directory in + (output_dir, File_util.files_under output_dir)) + in + let configured_suffixes = + List.map (Config.package_spec_suffix config) config.package_specs + in + let output_suffixes = + configured_suffixes @ generated_output_suffixes + |> List.sort_uniq (fun left right -> + let length_order = + Int.compare (String.length right) (String.length left) + in + if length_order = 0 then String.compare left right else length_order) + in + let output_details = generated_output_details_for_suffixes output_suffixes in + let present_public_outputs = Hashtbl.create 64 in + present_source_files @ List.concat_map snd output_files + |> List.iter (fun path -> + if Option.is_some (output_details path) && File_util.is_regular_file path + then Hashtbl.replace present_public_outputs path ()); + (* Expected outputs may use arbitrary configured suffixes, including suffixes + that do not resemble JavaScript. Probe those paths directly rather than + treating a filename heuristic as the source of truth. *) + List.iter + (fun module_ -> + List.iter + (fun spec -> + let output = + generated_js_path config module_.Source.implementation spec + in + if + (not (Hashtbl.mem present_public_outputs output)) + && File_util.is_regular_file output + then Hashtbl.replace present_public_outputs output ()) + config.package_specs) + modules; + let expected_artifacts = Hashtbl.create (List.length modules * 8) in + let owned_output_names = Hashtbl.create (List.length modules * 2) in + let current_sources = Hashtbl.create (List.length modules * 2) in + List.iter + (fun module_ -> + module_.Source.implementation :: Option.to_list module_.Source.interface + |> List.iter (fun source -> + Filename.concat root source |> Platform.normalize_path_for_comparison + |> fun source -> Hashtbl.replace current_sources source ())) + modules; + let stale_ast_basenames = Hashtbl.create 8 in + let removed_output_paths = Hashtbl.create 8 in + List.iter + (fun (ast_source : Compile_assets.ast_source) -> + let source = + Platform.normalize_path_for_comparison ast_source.source_path + in + if not (Hashtbl.mem current_sources source) then ( + Hashtbl.replace stale_ast_basenames + (Filename.basename ast_source.ast_path) + (); + Project_context.relative_to_opt root ast_source.source_path + |> Option.iter (fun relative_source -> + List.iter + (fun spec -> + Hashtbl.replace removed_output_paths + (generated_js_path config relative_source spec) + ()) + config.package_specs))) + ast_sources; + let add_expected base extensions = + List.iter + (fun extension -> + Hashtbl.replace expected_artifacts (base ^ extension) ()) + extensions + in + let previous_ast_count = ref 0 in + ocaml_files + |> List.iter (fun path -> + let basename = Filename.basename path in + if Filename.check_suffix basename ".ast" then ( + incr previous_ast_count; + Hashtbl.replace owned_output_names + (Filename.chop_suffix basename ".ast") + ()) + else if Filename.check_suffix basename ".iast" then ( + incr previous_ast_count; + Hashtbl.replace owned_output_names + (Filename.chop_suffix basename ".iast") + ())); + List.iter + (fun module_ -> + let source_base = + module_.Source.implementation |> Filename.basename + |> Filename.remove_extension + in + let compiler_base = + Source.compiler_asset_basename config module_.Source.implementation + in + Hashtbl.replace owned_output_names source_base (); + add_expected source_base [".ast"; ".res"]; + if Option.is_some module_.Source.interface then ( + add_expected source_base [".iast"; ".resi"]; + add_expected compiler_base [".cmti"]); + add_expected compiler_base [".cmi"; ".cmj"; ".cmt"]) + modules; + Config.namespace_compiler_name config.namespace + |> Option.iter (fun namespace -> + add_expected namespace [".cmi"; ".cmj"; ".cmt"; ".mlmap"]); + let removed_modules = ref [] in + (* Once the published CMI is removed, bsc still consults the working CMI to + produce its source-located missing-module diagnostic. Keep only that copy + through compilation; the command finalizer removes every deferred path. *) + let defer_working_cmi_until_after_compile basename = + Filename.check_suffix basename ".cmi" + in + let mapped_source_directories = + Hashtbl.create (List.length ast_sources * 2) + in + let add_mapped_source_directory key directory = + let directories = + Hashtbl.find_opt mapped_source_directories key |> Option.value ~default:[] + in + Hashtbl.replace mapped_source_directories key (directory :: directories) + in + List.iter + (fun (ast_source : Compile_assets.ast_source) -> + let source = ast_source.source_path in + Project_context.relative_to_opt root source + |> Option.iter (fun relative_source -> + let directory = Filename.dirname relative_source in + let source_base = + relative_source |> Filename.basename |> Filename.remove_extension + in + add_mapped_source_directory source_base directory; + add_mapped_source_directory + (Source.compiler_asset_basename config relative_source) + directory)) + ast_sources; + let directly_mapped_working_paths basename = + let extension = Filename.extension basename in + if extension = ".mlmap" then [Filename.concat build_dir basename] + else + Hashtbl.find_opt mapped_source_directories + (Filename.remove_extension basename) + |> Option.value ~default:[] + |> List.map (fun directory -> + Filename.concat build_dir (Filename.concat directory basename)) + |> List.sort_uniq String.compare + in + let working_paths basename = + match directly_mapped_working_paths basename with + | _ :: _ as paths -> paths + | [] -> + Hashtbl.find_opt (Lazy.force fallback_build_files_by_basename) basename + |> Option.value ~default:[] + in + ocaml_files + |> List.iter (fun path -> + let basename = Filename.basename path in + let managed = Compile_assets.is_managed_basename basename in + if + managed + && ((not (Hashtbl.mem expected_artifacts basename)) + || Hashtbl.mem stale_ast_basenames basename) + then ( + if + Filename.check_suffix basename ".ast" + || Filename.check_suffix basename ".iast" + then ( + let module_name = Source.module_name basename in + on_removed_module module_name; + removed_modules := module_name :: !removed_modules); + File_util.remove_file path; + working_paths basename + |> List.iter (fun build_path -> + if defer_working_cmi_until_after_compile basename then + on_deferred_artifact build_path + else File_util.remove_file build_path))); + let relative_under directory path = + let prefix = directory ^ Filename.dir_sep in + String.sub path (String.length prefix) + (String.length path - String.length prefix) + in + let expected_outputs = + Hashtbl.create (List.length modules * List.length config.package_specs) + in + List.iter + (fun module_ -> + List.iter + (fun spec -> + Hashtbl.replace expected_outputs + (generated_js_path config module_.Source.implementation spec) + ()) + config.package_specs) + modules; + let should_remove_output ~build_relative path = + output_details path + |> Option.fold ~none:false ~some:(fun (name, _, output_path) -> + (not (Hashtbl.mem expected_outputs output_path)) + && (Hashtbl.mem removed_output_paths output_path + || Hashtbl.mem owned_output_names name + (* A map alone is not enough provenance to delete a public file. The + mirrored output has the same relative path below lib/bs, so probe + that one path instead of scanning the entire working tree. *) + && is_local + && File_util.exists (Filename.concat build_dir build_relative))) + in + let planned_outputs = Hashtbl.create 16 in + let plan_output ~build_relative path = + output_details path + |> Option.iter (fun (_, _, output_path) -> + if should_remove_output ~build_relative output_path then + Hashtbl.replace planned_outputs output_path build_relative) + in + source_files + |> List.iter (fun path -> + output_details path + |> Option.iter (fun (_, _, output_path) -> + plan_output + ~build_relative:(relative_under root output_path) + output_path)); + ast_sources + |> List.iter (fun (ast_source : Compile_assets.ast_source) -> + let source = ast_source.source_path in + Project_context.relative_to_opt root source + |> Option.iter (fun relative_source -> + List.iter + (fun spec -> + let output = generated_js_path config relative_source spec in + plan_output ~build_relative:(relative_under root output) output) + config.package_specs)); + output_files + |> List.iter (fun (output_dir, files) -> + files + |> List.iter (fun path -> + output_details path + |> Option.iter (fun (_, _, output_path) -> + plan_output + ~build_relative:(relative_under output_dir output_path) + output_path))); + let remove_output (path, build_relative) = + Hashtbl.remove present_public_outputs path; + Hashtbl.remove present_public_outputs (path ^ ".map"); + File_util.remove_file path; + File_util.remove_file (path ^ ".map"); + let working_output = Filename.concat build_dir build_relative in + File_util.remove_file working_output; + File_util.remove_file (working_output ^ ".map") + in + planned_outputs |> Hashtbl.to_seq |> List.of_seq + |> List.sort (fun (left, _) (right, _) -> String.compare left right) + |> List.iter remove_output; + { + removed_modules = !removed_modules; + previous_ast_count = !previous_ast_count; + present_public_outputs; + } diff --git a/rewatch-ocaml/build_artifacts.mli b/rewatch-ocaml/build_artifacts.mli new file mode 100644 index 00000000000..dafa9d498c1 --- /dev/null +++ b/rewatch-ocaml/build_artifacts.mli @@ -0,0 +1,34 @@ +type cleanup_result = { + removed_modules: string list; + previous_ast_count: int; + present_public_outputs: (string, unit) Hashtbl.t; +} +(** Artifact cleanup first classifies ownership and builds a deletion plan. + Public JavaScript, source maps, and compiler artifacts are then removed as + one unit so an earlier deletion cannot erase evidence needed for a later + ownership decision. *) + +val lib_path : string -> string -> string +val relative_output_directory : string -> Config.package_spec -> string +val published_ast_path : ocaml_dir:string -> string -> string + +val generated_js_path : Config.t -> string -> Config.package_spec -> string + +val generated_build_js_path : + build_dir:string -> Config.t -> string -> Config.package_spec -> string + +val remove_public_outputs : Config.t -> string list -> unit + +val cleanup_stale : + ?ocaml_files:string list -> + ?ast_sources:Compile_assets.ast_source list -> + ?source_files:string list -> + ?present_source_files:string list -> + on_removed_module:(string -> unit) -> + on_deferred_artifact:(string -> unit) -> + root:string -> + ocaml_dir:string -> + is_local:bool -> + Config.t -> + Source.module_ list -> + cleanup_result diff --git a/rewatch-ocaml/build_attempt.ml b/rewatch-ocaml/build_attempt.ml new file mode 100644 index 00000000000..629522568f9 --- /dev/null +++ b/rewatch-ocaml/build_attempt.ml @@ -0,0 +1,212 @@ +type freshness_mode = Initialize_freshness | Reuse_freshness + +type compilation_kind = + | One_shot + | Initial_watch + | Incremental_watch + | Full_watch + +type parse_message = Parse_warning of string | Parse_error of string + +let has_parse_error messages = + List.exists + (function + | Parse_error _ -> true + | Parse_warning _ -> false) + messages + +type preliminary_parse = + | Parsed_successfully of {stderr: string} + | Parse_failed of {stdout: string; stderr: string} + | Use_existing_ast + +let preliminary_parse result = + if Process.succeeded result then Parsed_successfully {stderr = result.stderr} + else Parse_failed {stdout = result.stdout; stderr = result.stderr} + +type cleanup_batch = {actions: (unit -> unit) list; artifacts: string list} +type namespace_job = {job: Process.job; finish: Process.result -> unit} + +type pending_work = { + mutable namespace_jobs: namespace_job list; + mutable compile_candidates: Compiler_scheduler.candidate list; +} + +type finalization_state = { + results: (string, Build_artifacts.cleanup_result) Hashtbl.t; + mutable actions: (unit -> unit) list; + mutable artifacts: string list; + initialized_logs: (string, unit) Hashtbl.t; + mutable artifacts_cleaned: bool; + mutable logs_finalized: bool; +} + +type t = { + freshness_mode: freshness_mode; + session: Build_session.t; + mutable cleaned: int; + mutable previous_asts: int; + mutable parsed: int; + mutable compiled: int; + mutable parse_seconds: float; + mutable parse_messages: parse_message list; + mutable diagnostics: string list; + removed_modules: (string, unit) Hashtbl.t; + preliminary_parses: (string, preliminary_parse) Hashtbl.t; + blocked_modules: (string, unit) Hashtbl.t; + namespace_freshness: (string, float option) Hashtbl.t; + pending_work: pending_work; + finalization: finalization_state; + mutable compiler_cleaned: bool; + mutable had_warnings: bool; + process_poll: (unit -> unit) option; + progress: Output.Progress.t; + verbosity: int; +} + +let create ~freshness_mode ~session ~process_poll ~progress ~verbosity = + let cleanup_results = Hashtbl.create 32 in + Build_session.iter_public_outputs session (fun root present_public_outputs -> + Hashtbl.add cleanup_results root + Build_artifacts. + {removed_modules = []; previous_ast_count = 0; present_public_outputs}); + let removed_modules = Hashtbl.create 16 in + Build_session.pending_removed_modules session + |> List.iter (fun name -> Hashtbl.replace removed_modules name ()); + { + freshness_mode; + session; + cleaned = 0; + previous_asts = 0; + parsed = 0; + compiled = 0; + parse_seconds = 0.; + parse_messages = []; + diagnostics = []; + removed_modules; + preliminary_parses = Hashtbl.create 16; + blocked_modules = Hashtbl.create 16; + namespace_freshness = Hashtbl.create 16; + pending_work = {namespace_jobs = []; compile_candidates = []}; + finalization = + { + results = cleanup_results; + actions = []; + artifacts = []; + initialized_logs = Hashtbl.create 16; + artifacts_cleaned = false; + logs_finalized = false; + }; + compiler_cleaned = false; + had_warnings = false; + process_poll; + progress; + verbosity; + } + +let create_full ~warning_state ~process_poll ~progress ~verbosity = + create ~freshness_mode:Initialize_freshness + ~session:(Build_session.create ~warning_state) + ~process_poll ~progress ~verbosity + +let create_retained ~session ~process_poll ~progress ~verbosity = + let freshness_mode = + if Build_session.is_ready session then Reuse_freshness + else Initialize_freshness + in + create ~freshness_mode ~session ~process_poll ~progress ~verbosity + +let register_cleanup attempt action = + attempt.finalization.actions <- action :: attempt.finalization.actions + +let defer_artifact_cleanup attempt paths = + attempt.finalization.artifacts <- paths @ attempt.finalization.artifacts + +let take_cleanup attempt = + let batch = + { + actions = attempt.finalization.actions; + artifacts = attempt.finalization.artifacts; + } + in + attempt.finalization.actions <- []; + attempt.finalization.artifacts <- []; + batch + +let set_cleanup_result attempt root result = + Hashtbl.replace attempt.finalization.results root result; + Build_session.set_public_outputs attempt.session root + result.Build_artifacts.present_public_outputs + +let find_cleanup_result attempt root = + Hashtbl.find_opt attempt.finalization.results root + +let add_namespace_job attempt job = + attempt.pending_work.namespace_jobs <- + job :: attempt.pending_work.namespace_jobs + +let take_namespace_jobs attempt = + let jobs = List.rev attempt.pending_work.namespace_jobs in + attempt.pending_work.namespace_jobs <- []; + jobs + +let add_compile_candidates attempt candidates = + attempt.pending_work.compile_candidates <- + candidates @ attempt.pending_work.compile_candidates + +let take_compile_candidates attempt = + let candidates = attempt.pending_work.compile_candidates in + attempt.pending_work.compile_candidates <- []; + candidates + +let mark_log_initialized attempt root = + Hashtbl.replace attempt.finalization.initialized_logs root () + +let take_initialized_logs attempt = + let roots = + attempt.finalization.initialized_logs |> Hashtbl.to_seq_keys |> List.of_seq + in + Hashtbl.clear attempt.finalization.initialized_logs; + roots + +let run_all actions = + let first_error = ref None in + List.iter + (fun action -> + try action () + with error -> + if Option.is_none !first_error then first_error := Some error) + actions; + Option.iter raise !first_error + +let cleanup_artifacts attempt = + if not attempt.finalization.artifacts_cleaned then ( + attempt.finalization.artifacts_cleaned <- true; + let cleanup = take_cleanup attempt in + run_all + (cleanup.actions + @ List.map (fun path () -> File_util.remove_file path) cleanup.artifacts)) + +let finalize_logs attempt = + if not attempt.finalization.logs_finalized then ( + attempt.finalization.logs_finalized <- true; + let package_roots = take_initialized_logs attempt in + run_all + ((fun () -> Output.Progress.finish attempt.progress) + :: List.map + (fun package_root () -> Compiler_log.finalize package_root) + package_roots)) + +let finish_attempt attempt = + run_all + [(fun () -> cleanup_artifacts attempt); (fun () -> finalize_logs attempt)] + +let protect attempt action = + match action () with + | result -> + finish_attempt attempt; + result + | exception original -> ( + match finish_attempt attempt with + | () -> raise original + | exception cleanup_error -> raise cleanup_error) diff --git a/rewatch-ocaml/build_attempt.mli b/rewatch-ocaml/build_attempt.mli new file mode 100644 index 00000000000..811d2e4d85c --- /dev/null +++ b/rewatch-ocaml/build_attempt.mli @@ -0,0 +1,81 @@ +(** An attempt owns all mutable work and reporting state for one build. This + boundary lets finalization drain cleanup exactly once while the associated + {!Build_session} remains reusable after failures. *) + +type freshness_mode = Initialize_freshness | Reuse_freshness + +(** The compilation kind makes output and recovery policy explicit. In + particular, an initial watch build cannot assume that retained freshness + has already been initialized. *) +type compilation_kind = + | One_shot + | Initial_watch + | Incremental_watch + | Full_watch + +type parse_message = Parse_warning of string | Parse_error of string +val has_parse_error : parse_message list -> bool + +(** Preliminary parsing distinguishes successful new ASTs, failed source text, + and deliberately retained ASTs. Keeping these cases explicit prevents a + failed parse from being mistaken for an unchanged source. *) +type preliminary_parse = + | Parsed_successfully of {stderr: string} + | Parse_failed of {stdout: string; stderr: string} + | Use_existing_ast + +val preliminary_parse : Process.result -> preliminary_parse + +type namespace_job = {job: Process.job; finish: Process.result -> unit} +type pending_work +type finalization_state + +type t = { + freshness_mode: freshness_mode; + session: Build_session.t; + mutable cleaned: int; + mutable previous_asts: int; + mutable parsed: int; + mutable compiled: int; + mutable parse_seconds: float; + mutable parse_messages: parse_message list; + mutable diagnostics: string list; + removed_modules: (string, unit) Hashtbl.t; + preliminary_parses: (string, preliminary_parse) Hashtbl.t; + blocked_modules: (string, unit) Hashtbl.t; + namespace_freshness: (string, float option) Hashtbl.t; + pending_work: pending_work; + finalization: finalization_state; + mutable compiler_cleaned: bool; + mutable had_warnings: bool; + process_poll: (unit -> unit) option; + progress: Output.Progress.t; + verbosity: int; +} + +val create_full : + warning_state:Warning_state.t -> + process_poll:(unit -> unit) option -> + progress:Output.Progress.t -> + verbosity:int -> + t + +val create_retained : + session:Build_session.t -> + process_poll:(unit -> unit) option -> + progress:Output.Progress.t -> + verbosity:int -> + t + +val register_cleanup : t -> (unit -> unit) -> unit +val defer_artifact_cleanup : t -> string list -> unit +val set_cleanup_result : t -> string -> Build_artifacts.cleanup_result -> unit +val find_cleanup_result : t -> string -> Build_artifacts.cleanup_result option +val add_namespace_job : t -> namespace_job -> unit +val take_namespace_jobs : t -> namespace_job list +val add_compile_candidates : t -> Compiler_scheduler.candidate list -> unit +val take_compile_candidates : t -> Compiler_scheduler.candidate list +val mark_log_initialized : t -> string -> unit +val cleanup_artifacts : t -> unit +val finalize_logs : t -> unit +val protect : t -> (unit -> 'a) -> 'a diff --git a/rewatch-ocaml/build_freshness.ml b/rewatch-ocaml/build_freshness.ml new file mode 100644 index 00000000000..93eb79b707e --- /dev/null +++ b/rewatch-ocaml/build_freshness.ml @@ -0,0 +1,35 @@ +let source_requires_parse source_modified artifact_modified = + match (source_modified, artifact_modified) with + | Some source_time, Some artifact_time -> source_time >= artifact_time + | Some _, None -> true + | None, _ -> false + +let source_is_not_older_than_ast compile_assets ~root ~source_mtimes path = + let absolute = Filename.concat root path in + let source_modified = + match Hashtbl.find_opt source_mtimes path with + | Some modified -> Some modified + | None -> File_util.modification_time absolute + in + let artifact_modified = + match Compile_assets.ast compile_assets absolute with + | Some ast -> Some ast.modified + | None -> + let published_ast = + Build_artifacts.published_ast_path + ~ocaml_dir:(Build_artifacts.lib_path root "ocaml") + path + in + let belongs_to_source = + try + match (Ast_header.read published_ast).source with + | Some source -> + Platform.normalize_path_for_comparison source + = Platform.normalize_path_for_comparison absolute + | None -> false + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> false + in + if belongs_to_source then File_util.modification_time published_ast + else None + in + source_requires_parse source_modified artifact_modified diff --git a/rewatch-ocaml/build_freshness.mli b/rewatch-ocaml/build_freshness.mli new file mode 100644 index 00000000000..4fb259a6fcb --- /dev/null +++ b/rewatch-ocaml/build_freshness.mli @@ -0,0 +1,6 @@ +val source_is_not_older_than_ast : + Compile_assets.t -> + root:string -> + source_mtimes:(string, float) Hashtbl.t -> + string -> + bool diff --git a/rewatch-ocaml/build_lock.ml b/rewatch-ocaml/build_lock.ml new file mode 100644 index 00000000000..204a66c860c --- /dev/null +++ b/rewatch-ocaml/build_lock.ml @@ -0,0 +1,192 @@ +type watch = {path: string; pid: string} + +let read_owner_contents = File_util.read_file + +let read_owner path = + try Some (read_owner_contents path) + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None + +let read_owner_for_release = read_owner + +let valid_owner value = + match Int64.of_string_opt value with + | Some pid -> pid >= 0L && pid <= 0xffff_ffffL + | None -> false + +let malformed_error () = + Project_context.Error + "Could not start Rescript build: Could not parse lockfile PID\n\ + \ (try removing it and running the command again)" + +let process_is_active ?poll value = + Platform.process_is_active value ~run:(fun program args -> + try + let result = + Process.run ?poll ~cwd:(Filename.get_temp_dir_name ()) program args + in + Some (result.Process.status, result.stdout) + with Process.Error _ | Unix.Unix_error _ | Sys_error _ -> None) + +let with_candidate ~lock_dir prefix pid action = + (* Termination is deferred until candidate cleanup has an owner because the + watcher's signal handlers raise asynchronous exceptions. Without this + protected setup, a signal could leave the temporary file or its output + channel behind between two otherwise ordinary OCaml expressions. *) + let deferred_signals = Signal_restore.create ~defer:true in + let candidate = ref None in + let channel = ref None in + try + let path, output = + Filename.open_temp_file ~temp_dir:lock_dir prefix ".tmp" + in + candidate := Some path; + channel := Some output; + output_string output pid; + close_out output; + channel := None; + Fun.protect + ~finally:(fun () -> File_util.remove_file_best_effort path) + (fun () -> + Signal_restore.restore deferred_signals; + action path) + with exception_raised -> + Option.iter close_out_noerr !channel; + Option.iter File_util.remove_file_best_effort !candidate; + raise + (Signal_restore.exception_after_restore deferred_signals exception_raised) + +let clear_stale ?poll ~candidate path = + let takeover = path ^ ".takeover" in + try + Unix.link candidate takeover; + Fun.protect + ~finally:(fun () -> File_util.remove_file_best_effort takeover) + (fun () -> + match read_owner path with + | Some owner when not (valid_owner owner) -> raise (malformed_error ()) + | Some owner when process_is_active ?poll owner -> () + | Some _ -> File_util.remove_file_best_effort path + | None -> ()); + true + with Unix.Unix_error (Unix.EEXIST, _, _) -> + (match read_owner takeover with + | Some owner when process_is_active ?poll owner -> () + | _ -> File_util.remove_file_best_effort takeover); + false + +let unlink_existing path = + try Unix.unlink path with Unix.Unix_error (Unix.ENOENT, _, _) -> () + +let release_owned path pid = + if read_owner_for_release path = Some pid then unlink_existing path + +type owned_lock = {path: string; pid: string; mutable released: bool} + +let release lock = + if not lock.released then ( + release_owned lock.path lock.pid; + lock.released <- true) + +let attempt_link ~candidate ~path = + let deferred_signals = Signal_restore.create ~defer:true in + let linked = + try + Unix.link candidate path; + true + with + | Unix.Unix_error (Unix.EEXIST, _, _) -> false + | exception_raised -> + raise + (Signal_restore.exception_after_restore deferred_signals + exception_raised) + in + (linked, deferred_signals) + +let with_acquired ~candidate ~path ~pid ~deferred_signals action = + let lock = {path; pid; released = false} in + match + Signal_restore.protect deferred_signals (fun () -> + unlink_existing candidate); + action lock + with + | result -> + release lock; + result + | exception original -> ( + match release lock with + | () -> raise original + | exception cleanup_error -> raise cleanup_error) + +let retry_delay poll = + (try ignore (Unix.select [] [] [] 0.05) + with Unix.Unix_error (Unix.EINTR, _, _) -> ()); + poll () + +let with_build ?(poll = fun () -> ()) root action = + let lock_dir = Filename.concat root "lib" in + File_util.ensure_dir lock_dir; + let path = Filename.concat lock_dir "build.lock" in + let pid = string_of_int (Platform.current_process_id ()) in + with_candidate ~lock_dir ".build-lock-" pid (fun candidate -> + let rec acquire attempts = + poll (); + if attempts = 0 then + raise + (Project_context.Error + "Timed out waiting for another ReScript build to finish"); + let linked, deferred_signals = attempt_link ~candidate ~path in + if linked then + with_acquired ~candidate ~path ~pid ~deferred_signals (fun lock -> + action ~release:(fun () -> release lock)) + else ( + Signal_restore.restore deferred_signals; + match read_owner path with + | Some owner when not (valid_owner owner) -> + raise (malformed_error ()) + | Some owner when process_is_active ~poll owner -> + if attempts = 1200 then ( + print_endline "Waiting for other build to finish..."; + flush stdout); + retry_delay poll; + acquire (attempts - 1) + | _ -> + if not (clear_stale ~poll ~candidate path) then retry_delay poll; + acquire (attempts - 1)) + in + acquire 1200) + +let with_watch root action = + let lock_dir = Filename.concat root "lib" in + File_util.ensure_dir lock_dir; + let path = Filename.concat lock_dir "watch.lock" in + let pid = string_of_int (Platform.current_process_id ()) in + with_candidate ~lock_dir ".watch-lock-" pid (fun candidate -> + let rec acquire attempts = + if attempts = 0 then + raise + (Project_context.Error + "Timed out recovering a stale ReScript watch lock"); + let linked, deferred_signals = attempt_link ~candidate ~path in + if linked then + with_acquired ~candidate ~path ~pid ~deferred_signals (fun _ -> + action {path; pid}) + else ( + Signal_restore.restore deferred_signals; + match read_owner path with + | Some owner when not (valid_owner owner) -> + raise (malformed_error ()) + | Some owner when process_is_active owner -> + raise + (Project_context.Error + (Printf.sprintf + "Could not start Rescript build: A ReScript build is \ + already running. The process ID (PID) is %s" + owner)) + | _ -> + if not (clear_stale ~candidate path) then + ignore (Unix.select [] [] [] 0.01); + acquire (attempts - 1)) + in + acquire 1000) + +let is_owned (watch : watch) = read_owner watch.path = Some watch.pid diff --git a/rewatch-ocaml/build_lock.mli b/rewatch-ocaml/build_lock.mli new file mode 100644 index 00000000000..9c4015800d6 --- /dev/null +++ b/rewatch-ocaml/build_lock.mli @@ -0,0 +1,8 @@ +type watch + +val read_owner : string -> string option +val valid_owner : string -> bool +val with_build : + ?poll:(unit -> unit) -> string -> (release:(unit -> unit) -> 'a) -> 'a +val with_watch : string -> (watch -> 'a) -> 'a +val is_owned : watch -> bool diff --git a/rewatch-ocaml/build_preparation.ml b/rewatch-ocaml/build_preparation.ml new file mode 100644 index 00000000000..62f9d36e211 --- /dev/null +++ b/rewatch-ocaml/build_preparation.ml @@ -0,0 +1,262 @@ +exception Error = Project_context.Error + +type result = { + prepared: Build_session.prepared; + cycle: Module_graph.cycle_info option; +} + +let bsc_path () = + try Toolchain.bsc () with Toolchain.Error message -> raise (Error message) + +let runtime_path root = + try Toolchain.runtime ~find_package:(Project_context.dependency_path root) + with Toolchain.Error message -> raise (Error message) + +let run ~(root_config : Config.t) ~prod ~features ~warn_error ~filter ~watch + ~(attempt : Build_attempt.t) ~parse_step ~on_cleanup = + let bsc = bsc_path () in + let package_plans = + Package_graph.discover ~root_config ~prod ~features ~warn_error ~filter + ~attempt + in + Module_graph.validate_visible_namespaces ~root_config package_plans; + let runtime = runtime_path root_config.root in + let source_map_args = Compiler_args.source_map_args root_config ~watch in + let compiler_context = + Compiler_info.make_context ~build_root:root_config.root ~bsc_path:bsc + ~runtime_path:runtime ~source_map_args + ~inherited_compiler_args: + (root_config.jsx_args @ root_config.experimental_args) + ~package_output_specs:(Compiler_info.package_output_specs root_config) + in + let previous_compile_assets = + package_plans + |> List.map (fun (package : Package_plan.t) -> package.ocaml_dir) + |> Compile_assets.create + in + let registered_removed_modules = Hashtbl.create 16 in + let dependents_by_raw_dependency = Hashtbl.create 64 in + package_plans + |> List.concat_map (fun (package : Package_plan.t) -> + Compile_assets.ast_sources previous_compile_assets package.ocaml_dir) + |> List.iter (fun (ast_source : Compile_assets.ast_source) -> + Compile_assets.ast_dependencies previous_compile_assets + ast_source.ast_path + |> List.iter (fun dependency -> + let dependents = + Hashtbl.find_opt dependents_by_raw_dependency dependency + |> Option.value ~default:[] + in + Hashtbl.replace dependents_by_raw_dependency dependency + (ast_source.ast_path :: dependents))); + let invalidate_removed_module module_name = + if not (Hashtbl.mem registered_removed_modules module_name) then ( + Hashtbl.add registered_removed_modules module_name (); + Hashtbl.replace attempt.removed_modules module_name (); + Build_session.mark_module_removed attempt.session module_name; + let dependent_asts = + Hashtbl.find_opt dependents_by_raw_dependency module_name + |> Option.value ~default:[] + in + (* A cleanup failure can end the attempt before dependency preparation + records why consumers need recompilation. Remove their freshness + markers when the attempt finishes so the next invocation cannot reuse + them, while the current invocation can still compile from the existing + AST and report the source-level missing-module error. *) + Build_attempt.register_cleanup attempt (fun () -> + List.iter File_util.remove_file dependent_asts)) + in + let cleanup_started = Unix.gettimeofday () in + List.iter + (fun (package : Package_plan.t) -> + let package_context = + Compiler_info.for_package compiler_context + ~build_root:package.build_owner package.compile_config + in + if Compiler_info.needs_clean package_context package.config then ( + Compiler_info.changed_package_output_specs package_context + package.config + |> Option.iter (fun previous_specs -> + let previous_config = + Compiler_info.config_with_package_output_specs + package.compile_config previous_specs + in + let previous_implementations = + List.map + (fun (module_ : Source.module_) -> module_.implementation) + package.modules + @ (Compile_assets.ast_sources previous_compile_assets + package.ocaml_dir + |> List.filter_map (fun (source : Compile_assets.ast_source) -> + if Filename.check_suffix source.ast_path ".iast" then None + else + Project_context.relative_to_opt package.root + source.source_path)) + |> List.sort_uniq String.compare + in + Build_artifacts.remove_public_outputs previous_config + previous_implementations); + ignore + (Build_artifacts.cleanup_stale + ~ocaml_files: + (Compile_assets.files previous_compile_assets package.ocaml_dir) + ~ast_sources: + (Compile_assets.ast_sources previous_compile_assets + package.ocaml_dir) + ~root:package.root ~ocaml_dir:package.ocaml_dir + ~source_files:package.source_files + ~present_source_files:package.present_source_files + ~on_removed_module:invalidate_removed_module + ~on_deferred_artifact:File_util.remove_file + ~is_local:package.is_local package.compile_config package.modules); + Compiler_info.clean_package package.config; + attempt.compiler_cleaned <- true); + File_util.ensure_dir package.build_dir; + File_util.ensure_dir package.ocaml_dir) + package_plans; + List.iter + (fun (package : Package_plan.t) -> + let cleanup = + Build_artifacts.cleanup_stale + ~ocaml_files: + (Compile_assets.files previous_compile_assets package.ocaml_dir) + ~ast_sources: + (Compile_assets.ast_sources previous_compile_assets + package.ocaml_dir) + ~root:package.root ~ocaml_dir:package.ocaml_dir + ~source_files:package.source_files + ~present_source_files:package.present_source_files + ~on_removed_module:invalidate_removed_module + ~on_deferred_artifact:(fun path -> + Build_attempt.defer_artifact_cleanup attempt [path]) + ~is_local:package.is_local package.compile_config package.modules + in + Build_attempt.set_cleanup_result attempt package.root cleanup; + attempt.cleaned <- attempt.cleaned + List.length cleanup.removed_modules; + attempt.previous_asts <- + attempt.previous_asts + cleanup.previous_ast_count; + ()) + package_plans; + let compile_assets = + if attempt.compiler_cleaned || Hashtbl.length registered_removed_modules > 0 + then + package_plans + |> List.map (fun (package : Package_plan.t) -> package.ocaml_dir) + |> Compile_assets.create + else previous_compile_assets + in + on_cleanup (Unix.gettimeofday () -. cleanup_started); + let parse_started = Unix.gettimeofday () in + let parse_entries = + package_plans + |> List.concat_map (fun (package : Package_plan.t) -> + package.modules + |> List.concat_map (fun module_ -> + let paths = + module_.Source.implementation + :: Option.to_list module_.Source.interface + in + let dirty_paths = + paths + |> List.filter (fun path -> + Build_freshness.source_is_not_older_than_ast compile_assets + ~root:package.root ~source_mtimes:package.source_mtimes path) + in + if dirty_paths <> [] then + Output.debug ~verbosity:attempt.verbosity + ("Generating AST for module: " + ^ Source.compiler_basename package.compile_config + module_.Source.name); + let group = package.root ^ "\000" ^ module_.Source.name in + List.map (fun path -> (package, path, group)) dirty_paths)) + in + let parse_completed = + Output.Progress.start_grouped attempt.progress ~step:parse_step + ~symbol:Platform.parse_symbol ~label:"Parsing" + (List.map (fun (_, _, group) -> group) parse_entries) + in + let parse_results = + Process.run_parallel_map ?poll:attempt.process_poll + ~on_complete:parse_completed parse_entries + ~job:(fun ((package : Package_plan.t), path, _) -> + Compiler_process.parse_job ~bsc ~build_dir:package.build_dir + ~config:package.compile_config path) + in + let failed_parse_paths = Hashtbl.create 8 in + List.iter2 + (fun ((package : Package_plan.t), path, _) result -> + let absolute_path = Filename.concat package.root path in + let outcome = Build_attempt.preliminary_parse result in + Hashtbl.replace attempt.preliminary_parses absolute_path outcome; + match outcome with + | Build_attempt.Parse_failed _ -> + Hashtbl.replace failed_parse_paths absolute_path () + | Build_attempt.Parsed_successfully _ | Build_attempt.Use_existing_ast -> + ()) + parse_entries parse_results; + let graph = + Module_graph.initialize ~root_config ~package_plans ~compile_assets + ~failed_parse_paths + in + List.iter + (fun path -> + if not (Hashtbl.mem attempt.preliminary_parses path) then + Hashtbl.replace attempt.preliminary_parses path + Build_attempt.Use_existing_ast) + graph.use_existing_ast_paths; + List.iter + (fun (node : Module_graph.module_node) -> + Build_session.add_global_module attempt.session node.key node) + graph.nodes; + List.iter + (Build_session.add_namespace_map attempt.session) + graph.namespace_maps; + let nodes = graph.nodes in + let namespace_maps = graph.namespace_maps in + let build_state = graph.build_state in + let packages = Hashtbl.create (List.length package_plans) in + List.iter + (fun (package : Package_plan.t) -> + let regular_dependency_dirs, development_dependency_dirs = + List.fold_left + (fun (regular, development) (dependency : Package_plan.dependency) -> + let directory = + Build_artifacts.lib_path dependency.directory "ocaml" + in + if not (File_util.exists directory) then (regular, development) + else + match dependency.kind with + | Package_traversal.Regular -> (directory :: regular, development) + | Package_traversal.Development -> + (regular, directory :: development)) + ([], []) package.dependencies + |> fun (regular, development) -> (List.rev regular, List.rev development) + in + let common_args dependency_dirs = + Compiler_args.compiler_common_arguments ~config:package.compile_config + ~runtime ~dependency_dirs ~watch + ~gentype_dependency_args:package.gentype_dependency_args + in + let parse_paths = + package.modules + |> List.concat_map (fun module_ -> + module_.Source.implementation + :: Option.to_list module_.Source.interface) + in + Hashtbl.add packages package.root + Package_plan. + { + regular_common_args = common_args regular_dependency_dirs; + development_common_args = + common_args (development_dependency_dirs @ regular_dependency_dirs); + parse_paths; + }) + package_plans; + let prepared = + Build_session.{compiler_context; compile_assets; build_state; packages} + in + Build_session.install_prepared attempt.session prepared; + let cycle = Module_graph.find_cycle nodes namespace_maps build_state in + Build_session.set_graph_cycle attempt.session cycle; + attempt.parse_seconds <- Unix.gettimeofday () -. parse_started; + {prepared; cycle} diff --git a/rewatch-ocaml/build_preparation.mli b/rewatch-ocaml/build_preparation.mli new file mode 100644 index 00000000000..75a95cd78a9 --- /dev/null +++ b/rewatch-ocaml/build_preparation.mli @@ -0,0 +1,16 @@ +type result = { + prepared: Build_session.prepared; + cycle: Module_graph.cycle_info option; +} + +val run : + root_config:Config.t -> + prod:bool -> + features:string list option -> + warn_error:string option -> + filter:Source_filter.t option -> + watch:bool -> + attempt:Build_attempt.t -> + parse_step:string -> + on_cleanup:(float -> unit) -> + result diff --git a/rewatch-ocaml/build_report.ml b/rewatch-ocaml/build_report.ml new file mode 100644 index 00000000000..ea1e4e2e322 --- /dev/null +++ b/rewatch-ocaml/build_report.ml @@ -0,0 +1,126 @@ +type t = { + started_at: float; + interactive: bool; + show_progress: bool; + colors: bool; + no_timing: bool; + compilation_kind: Build_attempt.compilation_kind; + attempt: Build_attempt.t; +} + +let create ~started_at ~interactive ~show_progress ~colors ~no_timing + ~compilation_kind ~attempt = + { + started_at; + interactive; + show_progress; + colors; + no_timing; + compilation_kind; + attempt; + } + +let compile_step report = + match report.compilation_kind with + | Build_attempt.Incremental_watch -> "2/2" + | _ -> "3/3" + +let output_kind report = + match report.compilation_kind with + | Build_attempt.Initial_watch -> Output.Initial + | Build_attempt.Incremental_watch -> Output.Incremental + | Build_attempt.One_shot | Build_attempt.Full_watch -> Output.Standard + +let prepare report ~success ~compile_seconds = + let attempt = report.attempt in + if report.show_progress then + if report.interactive then + if success then + print_endline + (Output.compiling_message ~color:report.colors + ~step:(compile_step report) ~count:attempt.compiled + ~seconds:compile_seconds) + else + prerr_endline + (Output.compilation_failed_message ~color:report.colors + ~step:(compile_step report) ~count:attempt.compiled + ~seconds:compile_seconds) + else ( + (match report.compilation_kind with + | Build_attempt.One_shot | Build_attempt.Initial_watch + | Build_attempt.Full_watch -> + Printf.printf "Cleaned %d/%d\n%!" attempt.cleaned attempt.previous_asts + | Build_attempt.Incremental_watch -> ()); + Printf.printf "Parsed %d source files\n%!" attempt.parsed; + if success then Printf.printf "Compiled %d modules\n%!" attempt.compiled + else Printf.eprintf "Compiled %d modules\n%!" attempt.compiled); + let diagnostics = + match report.compilation_kind with + | Build_attempt.Incremental_watch | Build_attempt.Full_watch -> [] + | Build_attempt.One_shot | Build_attempt.Initial_watch -> + attempt.diagnostics |> List.rev |> List.sort_uniq String.compare + in + let warning_entries = + Warning_state.entries (Build_session.warning_state attempt.session) + in + List.iter + (fun entry -> prerr_string entry.Warning_state.output) + warning_entries; + if warning_entries <> [] && diagnostics = [] then prerr_newline (); + flush stderr; + if diagnostics <> [] then + diagnostics + |> List.map (fun diagnostic -> + if report.colors then Output.yellow diagnostic else diagnostic) + |> String.concat "\n\n" |> prerr_endline; + diagnostics + +let report_completion report diagnostics = + if report.interactive && report.show_progress then + let seconds = + if report.no_timing then 0. else Unix.gettimeofday () -. report.started_at + in + Printf.printf "\n%s\n%!" + (Output.finished_compilation_message ~label:(output_kind report) + ~warnings: + (report.attempt.had_warnings || diagnostics <> [] + || Warning_state.entries + (Build_session.warning_state report.attempt.session) + <> []) + ~seconds) + else if + report.compilation_kind <> Build_attempt.One_shot && report.show_progress + then + Printf.printf "Finished %scompilation\n%!" + (match report.compilation_kind with + | Build_attempt.Initial_watch -> "initial " + | Build_attempt.Incremental_watch -> "incremental " + | Build_attempt.One_shot | Build_attempt.Full_watch -> "") + +let report report ~success ~compile_seconds = + let diagnostics = prepare report ~success ~compile_seconds in + if success then report_completion report diagnostics + +let print_success_details report ~compile_seconds = + prepare report ~success:true ~compile_seconds + +let report_parse_failure report ~output = + (if report.interactive && report.show_progress then + prerr_endline + (Output.parsing_failed_message ~color:report.colors + ~step: + (match report.compilation_kind with + | Build_attempt.Incremental_watch -> "1/2" + | Build_attempt.One_shot | Build_attempt.Initial_watch + | Build_attempt.Full_watch -> + "2/3") + ~seconds: + (if report.no_timing then 0. else report.attempt.parse_seconds)) + else if report.show_progress then + match report.compilation_kind with + | Build_attempt.One_shot | Build_attempt.Initial_watch + | Build_attempt.Full_watch -> + Printf.printf "Cleaned %d/%d\n%!" report.attempt.cleaned + report.attempt.previous_asts + | Build_attempt.Incremental_watch -> ()); + prerr_endline output diff --git a/rewatch-ocaml/build_report.mli b/rewatch-ocaml/build_report.mli new file mode 100644 index 00000000000..fc133493776 --- /dev/null +++ b/rewatch-ocaml/build_report.mli @@ -0,0 +1,19 @@ +type t +(** Reporting owns presentation state independently of build execution. This + keeps terminal progress, timing, and final diagnostics from influencing + cleanup or retained-state transitions. *) + +val create : + started_at:float -> + interactive:bool -> + show_progress:bool -> + colors:bool -> + no_timing:bool -> + compilation_kind:Build_attempt.compilation_kind -> + attempt:Build_attempt.t -> + t + +val report : t -> success:bool -> compile_seconds:float -> unit +val print_success_details : t -> compile_seconds:float -> string list +val report_completion : t -> string list -> unit +val report_parse_failure : t -> output:string -> unit diff --git a/rewatch-ocaml/build_session.ml b/rewatch-ocaml/build_session.ml new file mode 100644 index 00000000000..d5ec0aa55ce --- /dev/null +++ b/rewatch-ocaml/build_session.ml @@ -0,0 +1,148 @@ +type prepared = { + compiler_context: Compiler_info.context; + compile_assets: Compile_assets.t; + build_state: Build_state.t; + packages: (string, Package_plan.compilation) Hashtbl.t; +} + +type source_reference = { + package_root: string; + module_: Source.module_; + relative_path: string; + absolute_path: string; +} + +type readiness = + | Not_prepared + | Freshness_pending of prepared + | Ready of prepared + +type compiler_info_state = Needs_publication | Published +type cycle_cache = + | Unknown_cycle + | Known_cycle of Module_graph.cycle_info option + +type t = { + global_modules: (string, Module_graph.module_node) Hashtbl.t; + namespace_maps: (string, Module_graph.namespace_map) Hashtbl.t; + namespace_maps_by_name: (string, Module_graph.namespace_map list) Hashtbl.t; + mutable graph_cycle: cycle_cache; + package_plans: (string, Package_plan.t) Hashtbl.t; + source_index: (string, source_reference) Hashtbl.t; + pending_parse_paths: (string, unit) Hashtbl.t; + pending_removed_modules: (string, unit) Hashtbl.t; + public_outputs: (string, (string, unit) Hashtbl.t) Hashtbl.t; + mutable readiness: readiness; + mutable compiler_info_state: compiler_info_state; + warning_state: Warning_state.t; +} + +let create ~warning_state = + { + global_modules = Hashtbl.create 64; + namespace_maps = Hashtbl.create 16; + namespace_maps_by_name = Hashtbl.create 16; + graph_cycle = Unknown_cycle; + package_plans = Hashtbl.create 32; + source_index = Hashtbl.create 64; + pending_parse_paths = Hashtbl.create 16; + pending_removed_modules = Hashtbl.create 16; + public_outputs = Hashtbl.create 32; + readiness = Not_prepared; + compiler_info_state = Needs_publication; + warning_state; + } + +let is_ready session = + match session.readiness with + | Ready _ -> true + | _ -> false + +let prepared session = + match session.readiness with + | Freshness_pending prepared | Ready prepared -> Some prepared + | Not_prepared -> None + +let install_prepared session prepared = + session.readiness <- Freshness_pending prepared + +let mark_freshness_initialized session = + match session.readiness with + | Freshness_pending prepared -> + session.readiness <- Ready prepared; + Hashtbl.clear session.pending_removed_modules + | Ready _ -> () + | Not_prepared -> invalid_arg "build state has not been prepared" + +let find_global_module session key = Hashtbl.find_opt session.global_modules key + +let add_global_module session key module_ = + Hashtbl.add session.global_modules key module_ + +let global_module_values session = + Hashtbl.to_seq_values session.global_modules |> List.of_seq + +let find_namespace_maps session name = + Hashtbl.find_opt session.namespace_maps_by_name name + +let add_namespace_map session namespace_map = + Hashtbl.add session.namespace_maps namespace_map.Module_graph.key + namespace_map; + let existing = + find_namespace_maps session namespace_map.namespace + |> Option.value ~default:[] + in + Hashtbl.replace session.namespace_maps_by_name namespace_map.namespace + (namespace_map :: existing) + +let find_namespace_map session key = Hashtbl.find session.namespace_maps key + +let namespace_map_values session = + Hashtbl.to_seq_values session.namespace_maps |> List.of_seq + +let graph_cycle session = session.graph_cycle +let invalidate_graph_cycle session = session.graph_cycle <- Unknown_cycle +let set_graph_cycle session cycle = session.graph_cycle <- Known_cycle cycle + +let add_package_plan session package = + Hashtbl.replace session.package_plans package.Package_plan.root package + +let find_package_plan session root = Hashtbl.find_opt session.package_plans root + +let iter_package_plans session f = Hashtbl.iter f session.package_plans +let package_plan_values session = Hashtbl.to_seq_values session.package_plans + +let publish_compiler_info session write = + match session.compiler_info_state with + | Published -> () + | Needs_publication -> + Hashtbl.iter (fun _ package -> write package) session.package_plans; + session.compiler_info_state <- Published + +let add_source_reference session normalized_path source = + Hashtbl.replace session.source_index normalized_path source + +let find_source_reference session normalized_path = + Hashtbl.find_opt session.source_index normalized_path + +let pending_parse_paths session = + session.pending_parse_paths |> Hashtbl.to_seq_keys |> List.of_seq + +let mark_parse_pending session path = + Hashtbl.replace session.pending_parse_paths path () + +let clear_parse_pending session path = + Hashtbl.remove session.pending_parse_paths path + +let mark_module_removed session name = + Hashtbl.replace session.pending_removed_modules name () + +let pending_removed_modules session = + session.pending_removed_modules |> Hashtbl.to_seq_keys |> List.of_seq + +let set_public_outputs session root outputs = + Hashtbl.replace session.public_outputs root outputs + +let iter_public_outputs session f = Hashtbl.iter f session.public_outputs + +let warning_state session = session.warning_state diff --git a/rewatch-ocaml/build_session.mli b/rewatch-ocaml/build_session.mli new file mode 100644 index 00000000000..847532a5193 --- /dev/null +++ b/rewatch-ocaml/build_session.mli @@ -0,0 +1,60 @@ +type t +(** A session keeps only state that must survive from one watch build to the + next. Per-attempt diagnostics, counters, and cleanup actions deliberately + live in {!Build_attempt} so a failed attempt cannot leak transient state + into its successor. *) + +type prepared = { + compiler_context: Compiler_info.context; + compile_assets: Compile_assets.t; + build_state: Build_state.t; + packages: (string, Package_plan.compilation) Hashtbl.t; +} + +type source_reference = { + package_root: string; + module_: Source.module_; + relative_path: string; + absolute_path: string; +} + +(** Cycle results are cached because ordinary implementation edits do not + change dependency edges. [Unknown_cycle] means that graph analysis is + required; [Known_cycle None] means it ran and found an acyclic graph. *) +type cycle_cache = + | Unknown_cycle + | Known_cycle of Module_graph.cycle_info option + +val create : warning_state:Warning_state.t -> t +val is_ready : t -> bool +val prepared : t -> prepared option +val install_prepared : t -> prepared -> unit +val mark_freshness_initialized : t -> unit +val find_global_module : t -> string -> Module_graph.module_node option +val add_global_module : t -> string -> Module_graph.module_node -> unit +val global_module_values : t -> Module_graph.module_node list +val find_namespace_maps : t -> string -> Module_graph.namespace_map list option +val add_namespace_map : t -> Module_graph.namespace_map -> unit +val find_namespace_map : t -> string -> Module_graph.namespace_map +val namespace_map_values : t -> Module_graph.namespace_map list +val graph_cycle : t -> cycle_cache +val invalidate_graph_cycle : t -> unit +val set_graph_cycle : t -> Module_graph.cycle_info option -> unit +val add_package_plan : t -> Package_plan.t -> unit +val find_package_plan : t -> string -> Package_plan.t option +val iter_package_plans : t -> (string -> Package_plan.t -> unit) -> unit +val package_plan_values : t -> Package_plan.t Seq.t +val publish_compiler_info : t -> (Package_plan.t -> unit) -> unit +val add_source_reference : t -> string -> source_reference -> unit +val find_source_reference : t -> string -> source_reference option +val pending_parse_paths : t -> string list +val mark_parse_pending : t -> string -> unit +val clear_parse_pending : t -> string -> unit +val mark_module_removed : t -> string -> unit +val pending_removed_modules : t -> string list + +val set_public_outputs : t -> string -> (string, unit) Hashtbl.t -> unit +val iter_public_outputs : + t -> (string -> (string, unit) Hashtbl.t -> unit) -> unit + +val warning_state : t -> Warning_state.t diff --git a/rewatch-ocaml/build_state.ml b/rewatch-ocaml/build_state.ml new file mode 100644 index 00000000000..ca06ad27235 --- /dev/null +++ b/rewatch-ocaml/build_state.ml @@ -0,0 +1,112 @@ +module String_set = Set.Make (String) + +type module_kind = Source_module | Namespace_map +type cmi_change = Cmi_changed | Cmi_unchanged | Cmi_change_unknown + +type module_ = { + key: string; + kind: module_kind; + mutable dependencies: string list; + mutable dependents: String_set.t; + mutable compile_dirty: bool; + mutable last_compiled_cmi: float option; + mutable last_compiled_cmt: float option; +} + +type t = {modules: (string, module_) Hashtbl.t} + +let create capacity = {modules = Hashtbl.create capacity} + +let add state ~key ~kind ~last_compiled_cmi ~last_compiled_cmt = + Hashtbl.add state.modules key + { + key; + kind; + dependencies = []; + dependents = String_set.empty; + compile_dirty = false; + last_compiled_cmi; + last_compiled_cmt; + } + +let find state key = Hashtbl.find_opt state.modules key + +let find_exn state key = + match find state key with + | Some module_ -> module_ + | None -> raise (Invalid_argument ("unknown build module " ^ key)) + +let has_complete_compile_assets module_ = + Option.is_some module_.last_compiled_cmi + && Option.is_some module_.last_compiled_cmt + +let dependency_tree_compiled_after ?(namespace_freshness = Hashtbl.create 4) + state module_ dependency = + let rec latest_cmi dependency = + match dependency.kind with + | Source_module -> dependency.last_compiled_cmi + | Namespace_map -> ( + match Hashtbl.find_opt namespace_freshness dependency.key with + | Some modified -> modified + | None -> + let modified = + dependency.dependencies + |> List.fold_left + (fun latest key -> + match (latest, latest_cmi (find_exn state key)) with + | None, member -> member + | latest, None -> latest + | Some latest, Some member -> Some (max latest member)) + None + in + Hashtbl.add namespace_freshness dependency.key modified; + modified) + in + match (latest_cmi dependency, module_.last_compiled_cmt) with + | Some dependency_time, Some module_time -> dependency_time > module_time + | None, _ | _, None -> false + +let set_dependencies state ~key dependencies = + let module_ = find_exn state key in + List.iter + (fun dependency -> + let dependency_module = find_exn state dependency in + dependency_module.dependents <- + String_set.remove key dependency_module.dependents) + module_.dependencies; + module_.dependencies <- dependencies; + List.iter + (fun dependency -> + let dependency_module = find_exn state dependency in + dependency_module.dependents <- + String_set.add key dependency_module.dependents) + dependencies + +let mark_dependents_compile_dirty ?(visited = Hashtbl.create 8) state module_ = + let rec mark dependent = + if not (Hashtbl.mem visited dependent) then ( + Hashtbl.add visited dependent (); + let dependent_module = find_exn state dependent in + match dependent_module.kind with + | Source_module -> dependent_module.compile_dirty <- true + | Namespace_map -> String_set.iter mark dependent_module.dependents) + in + String_set.iter mark module_.dependents + +let record_published_cmi ?dirty_propagation state ~compile_assets module_ ~path + change = + (match change with + | Cmi_unchanged -> () + | Cmi_changed | Cmi_change_unknown -> + mark_dependents_compile_dirty ?visited:dirty_propagation state module_); + Compile_assets.refresh_cmi compile_assets ~key:module_.key ~path; + module_.last_compiled_cmi <- + Compile_assets.cmi compile_assets module_.key + |> Option.map (fun entry -> entry.Compile_assets.modified) + +let record_successful_compile ~compile_assets module_ ~cmt_path = + Compile_assets.refresh_cmt compile_assets ~key:module_.key ~path:cmt_path; + module_.last_compiled_cmt <- + Compile_assets.cmt compile_assets module_.key + |> Option.map (fun entry -> entry.Compile_assets.modified); + module_.compile_dirty <- false diff --git a/rewatch-ocaml/build_state.mli b/rewatch-ocaml/build_state.mli new file mode 100644 index 00000000000..ef4f9b91f7a --- /dev/null +++ b/rewatch-ocaml/build_state.mli @@ -0,0 +1,60 @@ +module String_set : Set.S with type elt = string + +(** Build state is the authoritative mutable dependency and freshness store. + Compile-asset indexes supply filesystem metadata, but publication updates + graph dirtiness here so every successful or partial CMI publication follows + the same invalidation path. *) +type module_kind = Source_module | Namespace_map + +type cmi_change = Cmi_changed | Cmi_unchanged | Cmi_change_unknown + +type module_ = { + key: string; + kind: module_kind; + mutable dependencies: string list; + mutable dependents: String_set.t; + mutable compile_dirty: bool; + mutable last_compiled_cmi: float option; + mutable last_compiled_cmt: float option; +} + +type t + +val create : int -> t + +val add : + t -> + key:string -> + kind:module_kind -> + last_compiled_cmi:float option -> + last_compiled_cmt:float option -> + unit + +val find : t -> string -> module_ option +val find_exn : t -> string -> module_ +val has_complete_compile_assets : module_ -> bool +val dependency_tree_compiled_after : + ?namespace_freshness:(string, float option) Hashtbl.t -> + t -> + module_ -> + module_ -> + bool +val set_dependencies : t -> key:string -> string list -> unit + +val mark_dependents_compile_dirty : + ?visited:(string, unit) Hashtbl.t -> t -> module_ -> unit +(** Transitive propagation is required because a dependent may publish a + byte-identical CMI after recompilation. Marking only direct dependents would + then lose pending work below it. *) + +val record_published_cmi : + ?dirty_propagation:(string, unit) Hashtbl.t -> + t -> + compile_assets:Compile_assets.t -> + module_ -> + path:string -> + cmi_change -> + unit + +val record_successful_compile : + compile_assets:Compile_assets.t -> module_ -> cmt_path:string -> unit diff --git a/rewatch-ocaml/clean.ml b/rewatch-ocaml/clean.ml new file mode 100644 index 00000000000..7325403b3fa --- /dev/null +++ b/rewatch-ocaml/clean.ml @@ -0,0 +1,125 @@ +type package = { + root: string; + name: string; + output_config: Config.t; + implementation_files: string list; +} + +(* The complete cleanup plan is validated before deletion starts so a malformed + dependency cannot leave only the packages visited before it partially + cleaned. The resulting order remains dependency-first for progress output. *) +let prepare ~(root_config : Config.t) ~resolution ~seen ~prod ~is_local = + Package_diagnostics.validate_metadata root_config; + let packages = ref [] in + let rec visit (config : Config.t) ~is_local = + let root = config.root in + if not (Hashtbl.mem seen root) then ( + Hashtbl.add seen root (); + Package_diagnostics.report_missing_sources + ~is_root:(root = root_config.root) config; + (* A consumer clean owns dependencies previously built in this build + context, but not an independently built package's published tree. *) + let owns_outputs = + root <> root_config.root && Compiler_info.owns_outputs config + in + if not owns_outputs then ( + let dependencies = Package_traversal.requests ~prod ~is_local config in + let resolved_dependencies = + List.map + (fun request -> + Package_traversal.resolve resolution ~package_root:root request) + dependencies + in + List.iter + (fun (resolved : Package_traversal.resolved) -> + visit resolved.dependency.config + ~is_local:resolved.dependency.is_local) + resolved_dependencies; + let implementation_files = + Source.discover_for_cleanup config ~prod:(prod || not is_local) + ~on_missing: + (Package_diagnostics.report_missing_source_folder config) + in + let output_config = Config.with_root_options config root_config in + packages := + {root; name = config.name; output_config; implementation_files} + :: !packages)) + in + visit root_config ~is_local; + List.rev !packages + +let remove_compiler_assets packages ~on_clean = + List.iter + (fun package -> + on_clean package.name; + List.iter + (fun dir -> File_util.remove_tree (Filename.concat package.root dir)) + [Build_artifacts.lib_path "" "bs"; Build_artifacts.lib_path "" "ocaml"]) + packages + +let remove_generated_outputs packages = + List.iter + (fun package -> + List.iter + (fun implementation -> + List.iter + (fun spec -> + let output = + Build_artifacts.generated_js_path package.output_config + implementation spec + in + File_util.remove_file output; + File_util.remove_file (output ^ ".map")) + package.output_config.package_specs) + package.implementation_files) + packages + +let run ~poll ~verbosity ~folder ~prod = + let root = Project_context.canonical_project_root folder in + let show_progress = verbosity >= 0 in + let interactive = Unix.isatty Unix.stdout && Unix.isatty Unix.stderr in + let colors = Output.colors_enabled ~interactive in + let print_cleaning ~step target = + if interactive && show_progress then + Printf.printf "%s%!" + (Output.cleaning_command_message ~color:colors ~step target) + in + let print_cleaned ~step ~target ~started_at = + if interactive && show_progress then + print_endline + (Output.cleaned_command_message ~color:colors ~step ~target + ~seconds:(Unix.gettimeofday () -. started_at)) + in + Build_lock.with_build ~poll (Project_context.workspace_lock_root root) + (fun ~release:_ -> + poll (); + let root_config = Config.load_root root in + let resolution = Package_resolution.create root_config in + let cleanup = + prepare ~root_config ~resolution ~seen:(Hashtbl.create 32) ~prod + ~is_local:true + in + let compiler_assets = "compiler assets" in + let compiler_started = Unix.gettimeofday () in + remove_compiler_assets cleanup ~on_clean:(fun name -> + if show_progress then + if interactive then print_cleaning ~step:"1/2" name + else Printf.printf "Cleaning %s\n%!" name); + print_cleaned ~step:"1/2" ~target:compiler_assets + ~started_at:compiler_started; + poll (); + let suffixes = + root_config.package_specs + |> List.filter_map (fun (spec : Config.package_spec) -> + if spec.in_source then + Some (Config.package_spec_suffix root_config spec) + else None) + |> String.concat ", " + in + let generated_files = suffixes ^ " files" in + let generated_started = Unix.gettimeofday () in + print_cleaning ~step:"2/2" generated_files; + remove_generated_outputs cleanup; + poll (); + print_cleaned ~step:"2/2" ~target:generated_files + ~started_at:generated_started) diff --git a/rewatch-ocaml/clean.mli b/rewatch-ocaml/clean.mli new file mode 100644 index 00000000000..be5ef56c8cc --- /dev/null +++ b/rewatch-ocaml/clean.mli @@ -0,0 +1,2 @@ +val run : + poll:(unit -> unit) -> verbosity:int -> folder:string -> prod:bool -> unit diff --git a/rewatch-ocaml/cli.ml b/rewatch-ocaml/cli.ml new file mode 100644 index 00000000000..bf9c499e839 --- /dev/null +++ b/rewatch-ocaml/cli.ml @@ -0,0 +1,384 @@ +type command = + | Build of build_options + | Clean of {verbosity: int; folder: string; prod: bool} + | Watch of build_options + | Format of format_input + | Compiler_args of string + +and build_options = { + verbosity: int; + folder: string; + prod: bool; + features: string list option; + warn_error: string option; + after_build: string option; + filter: Source_filter.t option; + clear_screen: bool; + no_timing: bool; +} + +and format_input = + | Format_stdin of string + | Format_files of {check: bool; paths: string list} + +open Cmdliner +open Cmdliner.Term.Syntax + +let verbosity = + let verbose = + Arg.( + value & flag_all + & info ["v"; "verbose"] ~doc:"Increase logging verbosity.") + in + let quiet = + Arg.( + value & flag_all & info ["q"; "quiet"] ~doc:"Decrease logging verbosity.") + in + Term.term_result + (let+ verbose and+ quiet in + match (verbose, quiet) with + | _ :: _, _ :: _ -> + Error (`Msg "--verbose cannot be used together with --quiet") + | _ -> Ok (List.length verbose - List.length quiet)) + +let folder = + Arg.( + value & pos 0 string "." + & info [] ~docv:"FOLDER" + ~doc:"Path to the project or subproject containing rescript.json.") + +let prod = + Arg.( + value & flag + & info ["prod"] ~doc:"Skip development dependencies and sources.") + +let features = + let parse value = + let values = + String.split_on_char ',' value + |> List.map String.trim + |> List.filter (fun value -> value <> "") + in + if values = [] then + Error + (`Msg + "--features must not be empty. Omit the flag to build with all \ + features active.") + else Ok values + in + let print formatter values = + Stdlib.Format.pp_print_string formatter (String.concat "," values) + in + let converter = Arg.conv (parse, print) in + Arg.( + value + & opt (some converter) None + & info ["features"] ~docv:"FEATURES" + ~doc:"Restrict the current package to comma-separated features.") + +let warn_error = + Arg.( + value + & opt (some string) None + & info ["warn-error"] ~docv:"WARNINGS" + ~doc:"Override warning configuration from rescript.json.") + +let after_build = + Arg.( + value + & opt (some string) None + & info ["a"; "after-build"] ~docv:"COMMAND" + ~doc:"Run an additional command after a successful build.") + +let filter = + let parse value = + match Source_filter.compile value with + | Ok filter -> Ok filter + | Error message -> Error (`Msg message) + in + let print formatter filter = + Stdlib.Format.pp_print_string formatter (Source_filter.pattern filter) + in + Arg.( + value + & opt (some (conv (parse, print))) None + & info ["f"; "filter"] ~docv:"REGEX" + ~doc:"Filter source files by regular expression.") + +let no_timing = + Arg.(value & flag & info ["n"; "no-timing"] ~doc:"Disable output timing.") + +let clear_screen = + Arg.( + value & flag + & info ["clear-screen"] + ~doc:"Clear the terminal before each interactive rebuild.") + +let build_term ~watch = + let no_timing = if watch then Term.const false else no_timing in + let clear_screen = if watch then clear_screen else Term.const false in + let+ verbosity + and+ folder + and+ prod + and+ features + and+ warn_error + and+ after_build + and+ filter + and+ no_timing + and+ clear_screen in + let options : build_options = + { + verbosity; + folder; + prod; + features; + warn_error; + after_build; + filter; + clear_screen; + no_timing; + } + in + if watch then Watch options else Build options + +let clean_term = + let+ verbosity and+ folder and+ prod in + Clean {verbosity; folder; prod} + +let format_term = + let extension = Arg.enum [(".res", ".res"); (".resi", ".resi")] in + let stdin = + Arg.( + value + & opt (some extension) None + & info ["s"; "stdin"] ~docv:"EXTENSION" + ~doc:"Read stdin and write formatted source to stdout.") + in + let check = + Arg.( + value & flag + & info ["c"; "check"] ~doc:"Check formatting without modifying files.") + in + let files = Arg.(value & pos_all string [] & info [] ~docv:"FILES") in + Term.term_result + (let+ _verbosity = verbosity and+ check and+ stdin and+ files in + match (check, stdin, files) with + | true, Some _, _ -> Error (`Msg "--stdin conflicts with --check") + | _, Some _, _ :: _ -> Error (`Msg "files conflict with --stdin") + | _, Some extension, [] -> Ok (Format (Format_stdin extension)) + | _, None, paths -> Ok (Format (Format_files {check; paths}))) + +let compiler_args_term = + let path = + Arg.( + required + & pos 0 (some string) None + & info [] ~docv:"PATH" ~doc:"ReScript source file (.res or .resi).") + in + let+ _verbosity = verbosity and+ path in + Compiler_args path + +let command_info name doc = Cmd.info name ~doc + +let root = + let build = + Cmd.make + (command_info "build" "Build the project.") + (build_term ~watch:false) + in + let watch = + Cmd.make + (command_info "watch" "Build, then start a watcher.") + (build_term ~watch:true) + in + let clean = + Cmd.make (command_info "clean" "Clean build artifacts.") clean_term + in + let format = + Cmd.make (command_info "format" "Format ReScript files.") format_term + in + let compiler_args = + Cmd.make + (command_info "compiler-args" + "Print compiler arguments for a ReScript source file.") + compiler_args_term + in + let help = + let topic = + Arg.(value & pos 0 (some string) None & info [] ~docv:"COMMAND") + in + let help_term = + Term.ret + (let+ commands = Term.choice_names and+ topic in + match topic with + | None -> `Help (`Plain, None) + | Some command when List.mem command commands -> + `Help (`Plain, Some command) + | Some command -> + `Error (false, Printf.sprintf "unknown command %S" command)) + in + Cmd.make + (command_info "help" "Print this message or command help.") + help_term + in + let info = + let man = + [ + `S "NOTES"; + `P + "If no command is provided, the $(b,build) command is run by \ + default. See $(b,rescript help build) for more information."; + `P + "To create a new ReScript project, or to add ReScript to an existing \ + project, use https://github.com/rescript-lang/create-rescript-app."; + ] + in + Cmd.info "rescript" + ~version:("rescript " ^ Rewatch_version.version) + ~doc:"Fast, Simple, Fully Typed JavaScript from the Future" ~man + in + Cmd.group info ~default:(build_term ~watch:false) + [build; watch; clean; format; compiler_args; help] + +type evaluation = Run of command | Exit of int + +exception Parse_error of string +exception Help +exception Version + +let argv_is_utf_8 argv = Array.for_all String.is_valid_utf_8 argv + +(* Bare project folders must select the default build command, even though + Cmdliner otherwise treats them as unknown commands. Move global options + behind the selected command and expand short help/version clusters because + Cmdliner's standard display options only provide long names. A root display + request takes precedence over otherwise invalid implicit-build arguments. *) +let normalize_argv argv = + let is_short_global_cluster argument = + let length = String.length argument in + length > 1 + && argument.[0] = '-' + && argument.[1] <> '-' + && String.for_all + (function + | 'v' | 'q' | 'h' | 'V' -> true + | _ -> false) + (String.sub argument 1 (length - 1)) + in + let short_cluster_contains character argument = + is_short_global_cluster argument + && String.contains_from argument 1 character + in + let is_verbosity = function + | "-v" | "-vv" | "-vvv" | "-vvvv" | "--verbose" | "-q" | "-qq" | "-qqq" + | "-qqqq" | "--quiet" -> + true + | argument -> + is_short_global_cluster argument + && not + (short_cluster_contains 'h' argument + || short_cluster_contains 'V' argument) + in + let is_help = function + | "-h" | "--help" -> true + | argument -> String.starts_with ~prefix:"--help=" argument + in + let is_version = function + | "-V" | "--version" -> true + | _ -> false + in + let is_global argument = + is_short_global_cluster argument + || is_verbosity argument || is_help argument || is_version argument + in + let requests_help argument = + argument = "--help" + || String.starts_with ~prefix:"--help=" argument + || (is_short_global_cluster argument && short_cluster_contains 'h' argument) + in + let requests_version argument = + argument = "--version" + || (is_short_global_cluster argument && short_cluster_contains 'V' argument) + in + let is_command = function + | "build" | "watch" | "clean" | "format" | "compiler-args" | "help" -> true + | _ -> false + in + let rec normalize_display_options = function + | [] -> [] + | "--" :: rest -> "--" :: rest + | argument :: rest when String.starts_with ~prefix:"--help=" argument -> + argument :: normalize_display_options rest + | argument :: rest when requests_help argument -> + "--help=plain" :: normalize_display_options rest + | argument :: rest when requests_version argument -> + "--version" :: normalize_display_options rest + | argument :: rest -> argument :: normalize_display_options rest + in + let explicit_command arguments = + let rec loop globals = function + | [] | "--" :: _ -> None + | argument :: rest when is_global argument -> + loop (argument :: globals) rest + | command :: rest when is_command command -> + Some (List.rev globals, command, rest) + | _ -> None + in + loop [] arguments + in + let partition_implicit arguments = + let rec loop globals others = function + | [] -> (List.rev globals, List.rev others) + | "--" :: rest -> (List.rev globals, List.rev_append others ("--" :: rest)) + | argument :: rest when is_global argument -> + loop (argument :: globals) others rest + | argument :: rest -> loop globals (argument :: others) rest + in + loop [] [] arguments + in + match Array.to_list argv with + | [] -> argv + | executable :: arguments -> + let routed = + match explicit_command arguments with + | Some (globals, command, rest) -> + executable :: command :: (globals @ rest) + | None -> + let globals, others = partition_implicit arguments in + if + List.exists + (fun argument -> + requests_help argument || requests_version argument) + globals + then executable :: globals + else executable :: "build" :: (globals @ others) + in + Array.of_list (normalize_display_options routed) + +let eval argv = + if not (argv_is_utf_8 argv) then ( + prerr_endline "invalid UTF-8 in command-line argument"; + Exit 2) + else + match Cmd.eval_value ~catch:false ~argv:(normalize_argv argv) root with + | Ok (`Ok command) -> Run command + | Ok `Help | Ok `Version -> Exit 0 + | Error _ -> Exit 2 + +let parse argv = + if not (argv_is_utf_8 argv) then + raise (Parse_error "invalid UTF-8 in command-line argument"); + let help_buffer = Buffer.create 256 in + let error_buffer = Buffer.create 256 in + let help = Stdlib.Format.formatter_of_buffer help_buffer in + let err = Stdlib.Format.formatter_of_buffer error_buffer in + let result = + Cmd.eval_value ~catch:false ~help ~err ~argv:(normalize_argv argv) root + in + Stdlib.Format.pp_print_flush help (); + Stdlib.Format.pp_print_flush err (); + match result with + | Ok (`Ok command) -> command + | Ok `Help -> raise Help + | Ok `Version -> raise Version + | Error _ -> raise (Parse_error (Buffer.contents error_buffer)) diff --git a/rewatch-ocaml/cli.mli b/rewatch-ocaml/cli.mli new file mode 100644 index 00000000000..0f4a94c0857 --- /dev/null +++ b/rewatch-ocaml/cli.mli @@ -0,0 +1,31 @@ +type command = + | Build of build_options + | Clean of {verbosity: int; folder: string; prod: bool} + | Watch of build_options + | Format of format_input + | Compiler_args of string + +and build_options = { + verbosity: int; + folder: string; + prod: bool; + features: string list option; + warn_error: string option; + after_build: string option; + filter: Source_filter.t option; + clear_screen: bool; + no_timing: bool; +} + +and format_input = + | Format_stdin of string + | Format_files of {check: bool; paths: string list} + +type evaluation = Run of command | Exit of int + +exception Parse_error of string +exception Help +exception Version + +val eval : string array -> evaluation +val parse : string array -> command diff --git a/rewatch-ocaml/compile_assets.ml b/rewatch-ocaml/compile_assets.ml new file mode 100644 index 00000000000..20d8bc6c9ca --- /dev/null +++ b/rewatch-ocaml/compile_assets.ml @@ -0,0 +1,134 @@ +type entry = {path: string; modified: float} +type ast_source = {ast_path: string; source_path: string} + +type t = { + files_by_directory: (string, string list) Hashtbl.t; + ast_sources_by_directory: (string, ast_source list) Hashtbl.t; + ast_dependencies: (string, string list) Hashtbl.t; + ast_by_source: (string, entry) Hashtbl.t; + cmi_by_module: (string, entry) Hashtbl.t; + cmt_by_module: (string, entry) Hashtbl.t; +} + +let ast_header path = + try Some (Ast_header.read path) + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None + +let cleanup_extensions = + [".cmi"; ".cmj"; ".cmt"; ".cmti"; ".ast"; ".iast"; ".res"; ".resi"; ".mlmap"] + +let is_managed_basename basename = + List.exists (Filename.check_suffix basename) cleanup_extensions + +let state_extension = function + | ".ast" | ".iast" | ".cmi" | ".cmt" -> true + | _ -> false + +let read_directory directory = + let names = + try File_util.directory_entries directory + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> [] + in + let files = + names + |> List.filter (fun name -> + List.mem (Filename.extension name) cleanup_extensions) + |> List.map (Filename.concat directory) + in + let state_entries = + names + |> List.filter_map (fun name -> + if not (state_extension (Filename.extension name)) then None + else + let path = Filename.concat directory name in + try + let metadata = Unix.stat path in + if metadata.Unix.st_kind = Unix.S_DIR then None + else Some ({path; modified = metadata.Unix.st_mtime}, name) + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None) + in + (files, state_entries) + +let module_key name = + name |> Filename.remove_extension |> String.capitalize_ascii + +let source_key = Platform.normalize_path_for_comparison + +let add_module_artifact state (entry, name) = + match Filename.extension name with + | ".cmi" -> Hashtbl.replace state.cmi_by_module (module_key name) entry + | ".cmt" -> Hashtbl.replace state.cmt_by_module (module_key name) entry + | _ -> () + +let create directories = + let state = + { + files_by_directory = Hashtbl.create (List.length directories); + ast_sources_by_directory = Hashtbl.create (List.length directories); + ast_dependencies = Hashtbl.create 64; + ast_by_source = Hashtbl.create 64; + cmi_by_module = Hashtbl.create 64; + cmt_by_module = Hashtbl.create 64; + } + in + directories + |> List.sort_uniq String.compare + |> List.iter (fun directory -> + let files, state_entries = read_directory directory in + Hashtbl.replace state.files_by_directory directory files; + let ast_sources = + state_entries + |> List.filter_map (fun (entry, name) -> + match Filename.extension name with + | ".ast" | ".iast" -> ( + match ast_header entry.path with + | Some header -> + Option.map + (fun source -> (entry, source, header.dependencies)) + header.Ast_header.source + | None -> None) + | _ -> None) + in + Hashtbl.replace state.ast_sources_by_directory directory + (List.map + (fun (entry, source, _) -> + {ast_path = entry.path; source_path = source}) + ast_sources); + List.iter + (fun (entry, source, dependencies) -> + Hashtbl.replace state.ast_dependencies entry.path dependencies; + Hashtbl.replace state.ast_by_source (source_key source) entry) + ast_sources; + List.iter (add_module_artifact state) state_entries); + state + +let files state directory = + Hashtbl.find_opt state.files_by_directory directory + |> Option.value ~default:[] + +let ast_sources state directory = + Hashtbl.find_opt state.ast_sources_by_directory directory + |> Option.value ~default:[] + +let ast_dependencies state path = + Hashtbl.find_opt state.ast_dependencies path |> Option.value ~default:[] + +let ast state source = Hashtbl.find_opt state.ast_by_source (source_key source) + +let cmi state key = Hashtbl.find_opt state.cmi_by_module key +let cmt state key = Hashtbl.find_opt state.cmt_by_module key + +let replace_from_path table key path = + try + Hashtbl.replace table key {path; modified = (Unix.stat path).Unix.st_mtime} + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> + Hashtbl.remove table key + +let refresh_cmi state ~key ~path = + replace_from_path state.cmi_by_module key path + +let refresh_cmt state ~key ~path = + replace_from_path state.cmt_by_module key path + +let refresh_ast state ~source ~path = + replace_from_path state.ast_by_source (source_key source) path diff --git a/rewatch-ocaml/compile_assets.mli b/rewatch-ocaml/compile_assets.mli new file mode 100644 index 00000000000..53180ff4d62 --- /dev/null +++ b/rewatch-ocaml/compile_assets.mli @@ -0,0 +1,19 @@ +type entry = {path: string; modified: float} +(** This index avoids repeatedly scanning [lib/ocaml] while retaining the source + provenance embedded in AST headers. Mutating refresh operations must be + called whenever publication changes the corresponding filesystem entry. *) + +type ast_source = {ast_path: string; source_path: string} +type t + +val is_managed_basename : string -> bool +val create : string list -> t +val files : t -> string -> string list +val ast_sources : t -> string -> ast_source list +val ast_dependencies : t -> string -> string list +val ast : t -> string -> entry option +val cmi : t -> string -> entry option +val cmt : t -> string -> entry option +val refresh_cmi : t -> key:string -> path:string -> unit +val refresh_cmt : t -> key:string -> path:string -> unit +val refresh_ast : t -> source:string -> path:string -> unit diff --git a/rewatch-ocaml/compiler_args.ml b/rewatch-ocaml/compiler_args.ml new file mode 100644 index 00000000000..683be8a98fb --- /dev/null +++ b/rewatch-ocaml/compiler_args.ml @@ -0,0 +1,137 @@ +let ppx_is_enabled ~bisect_enabled flag contents = + if String_util.contains flag "bisect" then bisect_enabled + else + not + ((String_util.contains flag "graphql-ppx" + || String_util.contains flag "graphql_ppx") + && not (String_util.contains contents "%graphql") + || String_util.contains flag "spice" + && not (String_util.contains contents "@spice") + || String_util.contains flag "rescript-relay" + && not (String_util.contains contents "%relay") + || String_util.contains flag "re-formality" + && not (String_util.contains contents "%form")) + +let filter_ppx_flags ?bisect_enabled flags contents = + let bisect_enabled = + Option.value bisect_enabled + ~default:(Option.is_some (Sys.getenv_opt "BISECT_ENABLE")) + in + List.filter + (function + | [] -> false + | flag :: _ -> ppx_is_enabled ~bisect_enabled flag contents) + flags + +let source_map_args (config : Config.t) ~watch = + if config.source_map_dev && not watch then ["-bs-source-map"; "false"] + else config.source_map_args + +let compiler_flags ?(ppx_flags = []) ~source_maps ~watch ~gentype + (config : Config.t) = + let ppx_args = + ppx_flags + |> List.concat_map (function + | [] -> [] + | flag :: arguments -> + let executable = + match Project_context.dependency_path config.root flag with + | Some path -> path + | None -> flag + in + ["-ppx"; String.concat " " (executable :: arguments)]) + in + let source_map_args = + if source_maps then source_map_args config ~watch else [] + in + if source_maps then + ppx_args @ config.jsx_args @ source_map_args @ config.compiler_flags + @ config.warning_flags + @ (if gentype then config.gentype_args else []) + @ config.experimental_args + else + ppx_args @ config.jsx_args @ config.experimental_args @ config.warning_flags + @ config.compiler_flags + +let with_local_warning_policy ~is_local (config : Config.t) = + if is_local then config else {config with warning_flags = []} + +let package_output (config : Config.t) path (spec : Config.package_spec) = + let output_dir = Build_artifacts.relative_output_directory path spec in + Printf.sprintf "%s:%s:%s" + (Config.module_format_name spec.module_format) + output_dir + (Config.package_spec_suffix config spec) + +let gentype_dependency_args_from_paths (config : Config.t) dependencies = + if config.gentype_args = [] then [] + else + let paths = Hashtbl.create (List.length dependencies) in + List.iter + (fun ((dependency : Config.dependency), path) -> + Hashtbl.replace paths dependency.name path) + dependencies; + config.dependencies + |> List.concat_map (fun (dependency : Config.dependency) -> + match Hashtbl.find_opt paths dependency.name with + | None -> [] + | Some path -> ["-bs-gentype-dep-path"; dependency.name ^ "=" ^ path]) + +let namespace_args (config : Config.t) module_name = + match config.namespace with + | Config.No_namespace -> [] + | Config.Namespace namespace -> ["-bs-ns"; namespace] + | Config.Namespace_with_entry {name; entry} -> + if entry = module_name then ["-open"; "@" ^ name] + else ["-bs-ns"; "@" ^ name] + +let parser_arguments ~(config : Config.t) ~contents ~path = + compiler_flags + ~ppx_flags:(filter_ppx_flags config.ppx_flags contents) + ~source_maps:false ~watch:false ~gentype:false config + @ [ + "-absname"; + "-bs-ast"; + "-o"; + Source.ast_path path; + Filename.concat + (Filename.concat Filename.parent_dir_name Filename.parent_dir_name) + path; + ] + +let compiler_common_arguments ~(config : Config.t) ~runtime ~dependency_dirs + ~watch ~gentype_dependency_args = + ["-I"; Filename.concat Filename.parent_dir_name "ocaml"] + @ ["-runtime-path"; runtime] + @ List.concat_map (fun directory -> ["-I"; directory]) dependency_dirs + @ compiler_flags ~source_maps:true ~watch ~gentype:true config + @ gentype_dependency_args + @ ["-bs-package-name"; config.name; "-bs-project-root"; config.root] + +let compiler_arguments_with_common ~(config : Config.t) ~common_args + ~module_name ~source_kind ~has_interface ~path = + let interface_args = + match source_kind with + | Source.Implementation when has_interface -> ["-bs-read-cmi"] + | Source.Implementation | Source.Interface -> [] + in + let output_args = + match source_kind with + | Source.Interface -> [] + | Source.Implementation -> + List.concat_map + (fun spec -> ["-bs-package-output"; package_output config path spec]) + config.package_specs + in + namespace_args config module_name + @ interface_args @ common_args @ output_args + @ [Source.ast_path path] + +let compiler_arguments ~(config : Config.t) ~runtime ~dependency_dirs + ~module_name ~source_kind ~has_interface ~watch ~gentype_dependency_args + ~path = + compiler_arguments_with_common ~config + ~common_args: + (compiler_common_arguments ~config ~runtime ~dependency_dirs ~watch + ~gentype_dependency_args) + ~module_name ~source_kind ~has_interface ~path diff --git a/rewatch-ocaml/compiler_args.mli b/rewatch-ocaml/compiler_args.mli new file mode 100644 index 00000000000..df45b4a802f --- /dev/null +++ b/rewatch-ocaml/compiler_args.mli @@ -0,0 +1,48 @@ +val filter_ppx_flags : + ?bisect_enabled:bool -> string list list -> string -> string list list + +val source_map_args : Config.t -> watch:bool -> string list + +val compiler_flags : + ?ppx_flags:string list list -> + source_maps:bool -> + watch:bool -> + gentype:bool -> + Config.t -> + string list + +val with_local_warning_policy : is_local:bool -> Config.t -> Config.t +val gentype_dependency_args_from_paths : + Config.t -> (Config.dependency * string) list -> string list + +val parser_arguments : + config:Config.t -> contents:string -> path:string -> string list + +val compiler_arguments : + config:Config.t -> + runtime:string -> + dependency_dirs:string list -> + module_name:string -> + source_kind:Source.source_kind -> + has_interface:bool -> + watch:bool -> + gentype_dependency_args:string list -> + path:string -> + string list + +val compiler_common_arguments : + config:Config.t -> + runtime:string -> + dependency_dirs:string list -> + watch:bool -> + gentype_dependency_args:string list -> + string list + +val compiler_arguments_with_common : + config:Config.t -> + common_args:string list -> + module_name:string -> + source_kind:Source.source_kind -> + has_interface:bool -> + path:string -> + string list diff --git a/rewatch-ocaml/compiler_args_command.ml b/rewatch-ocaml/compiler_args_command.ml new file mode 100644 index 00000000000..0993b9e32e3 --- /dev/null +++ b/rewatch-ocaml/compiler_args_command.ml @@ -0,0 +1,99 @@ +let error message = raise (Project_context.Error message) + +let runtime_path resolution package_root = + try + Toolchain.runtime + ~find_package: + (Package_resolution.dependency_path resolution ~package_root) + with Toolchain.Error message -> error message + +let source_error path message = + error (Printf.sprintf "Could not read source file %s: %s" path message) + +let run path = + let source = + try + Filename.concat + (Platform.canonicalize_path (Filename.dirname path)) + (Filename.basename path) + with + | Sys_error message -> source_error path message + | Unix.Unix_error (unix_error, _, _) -> + source_error path (Unix.error_message unix_error) + in + if + not + (Filename.check_suffix source ".res" + || Filename.check_suffix source ".resi") + then error "compiler-args expects a .res or .resi source file"; + let package_config = + match Project_context.nearest_config_path (Filename.dirname source) with + | Some path -> Config.load path + | None -> error "could not find a rescript.json parent" + in + let root = Project_context.workspace_lock_root package_config.root in + let root_config_path = Config.path_in_root root in + let root_config = + if root <> package_config.root && Config.exists_in_root root then + Config.load root_config_path + else package_config + in + let resolution = + Package_resolution.create + ~diagnostic_mode:Package_resolution.Suppress_diagnostics root_config + in + let config = Config.with_root_options package_config root_config in + let relative = Project_context.relative_to config.root source in + let contents = + try File_util.read_file source with + | Sys_error message -> source_error path message + | Unix.Unix_error (unix_error, _, _) -> + source_error path (Unix.error_message unix_error) + in + let parser_args = + Compiler_args.parser_arguments ~config ~contents ~path:relative + in + let source_kind = + if Filename.check_suffix source ".resi" then Source.Interface + else Source.Implementation + in + let has_interface = + match source_kind with + | Source.Implementation -> File_util.exists (source ^ "i") + | Source.Interface -> false + in + let dependencies = + (if Config.source_is_dev config relative then + List.map (fun dependency -> (false, dependency)) config.dev_dependencies + else []) + @ List.map (fun dependency -> (true, dependency)) config.dependencies + in + let dependency_dirs = + dependencies + |> List.filter_map (fun (required, (dependency : Config.dependency)) -> + match + Package_resolution.dependency_path resolution + ~package_root:config.root dependency.name + with + | Some directory -> Some (Build_artifacts.lib_path directory "ocaml") + | None when not required -> None + | None -> + error + (Printf.sprintf "Expected to find dependent package %s of %s" + dependency.name config.name)) + in + let runtime = runtime_path resolution config.root in + let compiler_args = + Compiler_args.compiler_arguments ~config ~runtime ~dependency_dirs + ~module_name:(Source.module_name source) + ~source_kind ~has_interface ~watch:false ~gentype_dependency_args:[] + ~path:relative + in + Yojson.Safe.pretty_to_string + (`Assoc + [ + ( "compiler_args", + `List (List.map (fun value -> `String value) compiler_args) ); + ( "parser_args", + `List (List.map (fun value -> `String value) parser_args) ); + ]) diff --git a/rewatch-ocaml/compiler_args_command.mli b/rewatch-ocaml/compiler_args_command.mli new file mode 100644 index 00000000000..f9440843c61 --- /dev/null +++ b/rewatch-ocaml/compiler_args_command.mli @@ -0,0 +1 @@ +val run : string -> string diff --git a/rewatch-ocaml/compiler_info.ml b/rewatch-ocaml/compiler_info.ml new file mode 100644 index 00000000000..1207008ab15 --- /dev/null +++ b/rewatch-ocaml/compiler_info.ml @@ -0,0 +1,165 @@ +type context = { + build_root: string; + bsc_path: string; + bsc_hash: string; + runtime_path: string; + source_map_args: string list; + inherited_compiler_args: string list; + package_output_specs: package_output_spec list; +} + +and package_output_spec = { + module_format: Config.module_format; + in_source: bool; + suffix: string; +} + +let format_version = "4" + +let package_output_specs (config : Config.t) = + List.map + (fun (spec : Config.package_spec) -> + { + module_format = spec.module_format; + in_source = spec.in_source; + suffix = Config.package_spec_suffix config spec; + }) + config.package_specs + +let make_context ~build_root ~bsc_path ~runtime_path ~source_map_args + ~inherited_compiler_args ~package_output_specs = + { + build_root; + bsc_path; + bsc_hash = Digest.file bsc_path |> Digest.to_hex; + runtime_path; + source_map_args; + inherited_compiler_args; + package_output_specs; + } + +let for_package context ~build_root config = + {context with build_root; package_output_specs = package_output_specs config} + +let path root = File_util.path_of_parts root ["lib"; "bs"; "compiler-info.json"] + +let package_output_spec_json spec = + `Assoc + [ + ("module", `String (Config.module_format_name spec.module_format)); + ("in_source", `Bool spec.in_source); + ("suffix", `String spec.suffix); + ] + +let package_output_spec_of_json = function + | `Assoc fields -> ( + match + ( List.assoc_opt "module" fields, + List.assoc_opt "in_source" fields, + List.assoc_opt "suffix" fields ) + with + | ( Some (`String module_format), + Some (`Bool in_source), + Some (`String suffix) ) -> ( + match module_format with + | "esmodule" -> Some {module_format = Config.Esmodule; in_source; suffix} + | "commonjs" -> Some {module_format = Config.Commonjs; in_source; suffix} + | _ -> None) + | _ -> None) + | _ -> None + +let package_output_specs_of_json = function + | `Assoc fields -> ( + match List.assoc_opt "package_output_specs" fields with + | Some (`List values) -> + let specs = List.filter_map package_output_spec_of_json values in + if List.length specs = List.length values then Some specs else None + | _ -> None) + | _ -> None + +let build_root_of_json = function + | `Assoc fields -> ( + match List.assoc_opt "build_root" fields with + | Some (`String build_root) -> Some build_root + | _ -> None) + | _ -> None + +let read config = + try Some (Yojson.Safe.from_file (path config.Config.root)) + with Yojson.Json_error _ | Sys_error _ -> None + +let json context (config : Config.t) = + `Assoc + [ + ("version", `String format_version); + ("build_root", `String context.build_root); + ("bsc_path", `String context.bsc_path); + ("bsc_hash", `String context.bsc_hash); + ("rescript_config_hash", `String config.file_hash); + ( "source_map_args", + `List (List.map (fun value -> `String value) context.source_map_args) ); + ( "inherited_compiler_args", + `List + (List.map + (fun value -> `String value) + context.inherited_compiler_args) ); + ( "package_output_specs", + `List (List.map package_output_spec_json context.package_output_specs) + ); + ("runtime_path", `String context.runtime_path); + ] + +let same_path left right = + Platform.normalize_path_for_comparison left + = Platform.normalize_path_for_comparison right + +let owns_outputs (config : Config.t) = + match Option.bind (read config) build_root_of_json with + | Some build_root -> same_path build_root config.root + | None -> false + +let matches_json context config contents = contents = json context config + +let matches context config = + match read config with + | Some contents -> matches_json context config contents + | None -> false + +let changed_package_output_specs context config = + match read config with + | None -> None + | Some contents -> + Option.bind (package_output_specs_of_json contents) (fun previous -> + if previous = context.package_output_specs then None else Some previous) + +let config_with_package_output_specs (config : Config.t) specs = + let package_specs = + List.map + (fun spec : Config.package_spec -> + { + module_format = spec.module_format; + in_source = spec.in_source; + suffix = Some spec.suffix; + }) + specs + in + {config with package_specs} + +let previous_build_exists root = + File_util.exists + (File_util.path_of_parts root ["lib"; "ocaml"; ".compiler.log"]) + +let needs_clean context (config : Config.t) = + let info_path = path config.root in + if File_util.exists info_path then not (matches context config) + else previous_build_exists config.root + +let clean_package (config : Config.t) = + File_util.remove_tree (Build_artifacts.lib_path config.root "bs"); + File_util.remove_tree (Build_artifacts.lib_path config.root "ocaml") + +let write_package context (config : Config.t) = + if not (matches context config) then + let info_path = path config.root in + let contents = Yojson.Safe.pretty_to_string (json context config) ^ "\n" in + File_util.write_file_atomic ~perm:0o644 info_path contents diff --git a/rewatch-ocaml/compiler_info.mli b/rewatch-ocaml/compiler_info.mli new file mode 100644 index 00000000000..265cbf40bc3 --- /dev/null +++ b/rewatch-ocaml/compiler_info.mli @@ -0,0 +1,44 @@ +type context = { + build_root: string; + bsc_path: string; + bsc_hash: string; + runtime_path: string; + source_map_args: string list; + inherited_compiler_args: string list; + package_output_specs: package_output_spec list; +} +(** Compiler information fingerprints effective inputs rather than only the + package's own JSON. Root-level JSX, experimental options, runtime identity, + and output layout can all change dependency output without changing a + dependency configuration file. *) + +and package_output_spec = { + module_format: Config.module_format; + in_source: bool; + suffix: string; +} + +val package_output_specs : Config.t -> package_output_spec list + +val make_context : + build_root:string -> + bsc_path:string -> + runtime_path:string -> + source_map_args:string list -> + inherited_compiler_args:string list -> + package_output_specs:package_output_spec list -> + context + +val for_package : context -> build_root:string -> Config.t -> context + +val owns_outputs : Config.t -> bool + +val changed_package_output_specs : + context -> Config.t -> package_output_spec list option + +val config_with_package_output_specs : + Config.t -> package_output_spec list -> Config.t + +val needs_clean : context -> Config.t -> bool +val clean_package : Config.t -> unit +val write_package : context -> Config.t -> unit diff --git a/rewatch-ocaml/compiler_log.ml b/rewatch-ocaml/compiler_log.ml new file mode 100644 index 00000000000..7c16e65be3c --- /dev/null +++ b/rewatch-ocaml/compiler_log.ml @@ -0,0 +1,39 @@ +let path root directory = + Filename.concat (Build_artifacts.lib_path root directory) ".compiler.log" + +let strip_ansi content = + let length = String.length content in + let output = Buffer.create length in + let rec skip_csi index = + if index >= length then index + else + let code = Char.code content.[index] in + if code >= 0x40 && code <= 0x7e then index + 1 else skip_csi (index + 1) + in + let rec loop index = + if index < length then + if + (content.[index] = '\027' || content.[index] = '\155') + && index + 1 < length + && content.[index + 1] = '[' + then loop (skip_csi (index + 2)) + else ( + Buffer.add_char output content.[index]; + loop (index + 1)) + in + loop 0; + Buffer.contents output + +let initialize root = + let path = path root "bs" in + File_util.ensure_dir (Filename.dirname path); + File_util.write_file path + (Printf.sprintf "#Start(%.6f)\n" (Unix.gettimeofday ())) + +let append root content = + File_util.append_file (path root "bs") (strip_ansi content) + +let finalize root = + append root (Printf.sprintf "#Done(%.6f)\n" (Unix.gettimeofday ())); + File_util.copy_existing_file ~ensure_parent:false (path root "bs") + (path root "ocaml") diff --git a/rewatch-ocaml/compiler_log.mli b/rewatch-ocaml/compiler_log.mli new file mode 100644 index 00000000000..ab6bd3b51ca --- /dev/null +++ b/rewatch-ocaml/compiler_log.mli @@ -0,0 +1,6 @@ +val initialize : string -> unit +(** Compiler logs are finalized separately from artifact cleanup so one cleanup + failure cannot leave a successfully completed log in its temporary state. *) + +val append : string -> string -> unit +val finalize : string -> unit diff --git a/rewatch-ocaml/compiler_process.ml b/rewatch-ocaml/compiler_process.ml new file mode 100644 index 00000000000..94663d04458 --- /dev/null +++ b/rewatch-ocaml/compiler_process.ml @@ -0,0 +1,210 @@ +let retain_critical_external_warnings stderr = + let marker = "`(. ...)` uncurried syntax" in + if not (String_util.contains stderr marker) then "" + else + stderr + |> Str.global_replace (Str.regexp_string "\r\n") "\n" + |> Str.split_delim (Str.regexp_string "\n\n\n") + |> List.filter (fun block -> String_util.contains block marker) + |> String.concat "\n\n\n" + +let parse_job ~bsc ~build_dir ~(config : Config.t) path = + let ast = Source.ast_path path in + File_util.ensure_dir (Filename.concat build_dir (Filename.dirname ast)); + let contents = + if config.ppx_flags = [] then "" + else File_util.read_file (Filename.concat config.root path) + in + let args = Compiler_args.parser_arguments ~config ~contents ~path in + Process.{program = bsc; args; cwd = build_dir} + +let ast_dependencies ~build_dir ast = + (Ast_header.read (Filename.concat build_dir ast)).dependencies + +type compiler_artifact = Cmi | Required of string | Optional of string + +let publish_compiler_artifacts ~artifact_dir ~ocaml_dir ~basename artifacts = + let cmi_change = ref Compiler_scheduler.Cmi_change_unknown in + try + List.iter + (fun artifact -> + let extension = + match artifact with + | Cmi -> "cmi" + | Required name | Optional name -> name + in + let source = + Filename.concat artifact_dir (basename ^ "." ^ extension) + in + let destination = + Filename.concat ocaml_dir (basename ^ "." ^ extension) + in + match artifact with + | Cmi -> + cmi_change := + if + File_util.copy_file_if_different ~ensure_parent:false source + destination + then Compiler_scheduler.Cmi_changed + else Compiler_scheduler.Cmi_unchanged + | Required _ -> + File_util.copy_existing_file ~ensure_parent:false source destination + | Optional _ -> + File_util.copy_optional_existing_file ~ensure_parent:false source + destination) + artifacts; + !cmi_change + with error -> + raise (Compiler_scheduler.Publication_failure (error, !cmi_change)) + +let namespace_task ~bsc ~runtime ~build_dir ~ocaml_dir ~entry ~package_dirty + ~force namespace modules = + let mlmap = Filename.concat build_dir (namespace ^ ".mlmap") in + let contents = + let buffer = Buffer.create 128 in + Buffer.add_string buffer "randjbuildsystem\n"; + Source.namespace_members ~entry modules + |> List.map (fun module_ -> module_.Source.name) + |> List.sort String.compare + |> List.iter (fun name -> + Buffer.add_string buffer name; + Buffer.add_char buffer '\n'); + Buffer.contents buffer + in + let previous_contents = + try Some (File_util.read_file mlmap) + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None + in + let mlmap_changed = previous_contents <> Some contents in + if mlmap_changed then + File_util.write_file_atomic ~ensure_parent:false ~perm:0o644 mlmap contents; + let outputs_exist = + ["cmi"; "cmj"; "cmt"; "mlmap"] + |> List.for_all (fun extension -> + File_util.is_regular_file + (Filename.concat ocaml_dir (namespace ^ "." ^ extension))) + in + let published_mlmap_matches = + try + File_util.read_file (Filename.concat ocaml_dir (namespace ^ ".mlmap")) + = contents + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> false + in + if + not + (force || package_dirty || mlmap_changed + || (not published_mlmap_matches) + || not outputs_exist) + then None + else + Some + Compiler_scheduler. + { + job = + Process. + { + program = bsc; + args = + [ + "-runtime-path"; + runtime; + "-w"; + "-49"; + "-color"; + "always"; + "-no-alias-deps"; + Filename.basename mlmap; + ]; + cwd = build_dir; + }; + publish = + (fun result -> + if not (Process.succeeded result) then + raise + (Compiler_scheduler.Build_failure + (result.Process.stderr ^ result.stdout)); + let cmi_change = + publish_compiler_artifacts ~artifact_dir:build_dir ~ocaml_dir + ~basename:namespace + [Cmi; Required "cmj"; Required "cmt"; Required "mlmap"] + in + Compiler_scheduler.{stderr = result.stderr; cmi_change}); + } + +let post_build_tasks (config : Config.t) path = + match config.js_post_build with + | None -> [] + | Some command -> + List.map + (fun spec -> + let output = Build_artifacts.generated_js_path config path spec in + let command = Platform.post_build_command ~command ~output in + Compiler_scheduler. + { + output; + task = + Process.task ?env:command.env + Process. + { + program = command.program; + args = command.args; + cwd = config.root; + }; + }) + config.package_specs + +let compile_job ~bsc ~build_dir ~(config : Config.t) ~common_args + (module_ : Source.module_) ~source_kind path = + let args = + Compiler_args.compiler_arguments_with_common ~config ~common_args + ~module_name:module_.name ~source_kind + ~has_interface:(Option.is_some module_.interface) + ~path + in + Process.{program = bsc; args; cwd = build_dir} + +let publish ~build_dir ~ocaml_dir ~is_local ~(config : Config.t) ~source_kind + path result = + let stderr = + if is_local then result.Process.stderr + else retain_critical_external_warnings result.stderr + in + let basename = Source.compiler_asset_basename config path in + let artifact_dir = Filename.concat build_dir (Filename.dirname path) in + let cmi_change = ref Compiler_scheduler.Cmi_change_unknown in + try + cmi_change := + publish_compiler_artifacts ~artifact_dir ~ocaml_dir ~basename + (match source_kind with + | Source.Interface -> [Cmi; Optional "cmti"] + | Source.Implementation -> [Cmi; Required "cmj"; Optional "cmt"]); + let source = Filename.concat config.root path in + let build_source = Filename.concat build_dir path in + File_util.ensure_dir (Filename.dirname build_source); + File_util.copy_existing_file ~ensure_parent:false source build_source; + File_util.copy_existing_file ~ensure_parent:false source + (Filename.concat ocaml_dir (Filename.basename path)); + (match source_kind with + | Source.Interface -> () + | Source.Implementation -> + List.iter + (fun spec -> + if spec.Config.in_source then ( + let output = Build_artifacts.generated_js_path config path spec in + let build_output = + Build_artifacts.generated_build_js_path ~build_dir config path + spec + in + File_util.ensure_dir (Filename.dirname build_output); + if File_util.exists output then + File_util.copy_existing_file ~ensure_parent:false output + build_output; + if File_util.exists (output ^ ".map") then + File_util.copy_existing_file ~ensure_parent:false + (output ^ ".map") (build_output ^ ".map") + else File_util.remove_file (build_output ^ ".map"))) + config.package_specs); + Compiler_scheduler.{stderr; cmi_change = !cmi_change} + with + | Compiler_scheduler.Publication_failure _ as error -> raise error + | error -> raise (Compiler_scheduler.Publication_failure (error, !cmi_change)) diff --git a/rewatch-ocaml/compiler_process.mli b/rewatch-ocaml/compiler_process.mli new file mode 100644 index 00000000000..542ddd658a5 --- /dev/null +++ b/rewatch-ocaml/compiler_process.mli @@ -0,0 +1,39 @@ +val retain_critical_external_warnings : string -> string +val parse_job : + bsc:string -> build_dir:string -> config:Config.t -> string -> Process.job +val ast_dependencies : build_dir:string -> string -> string list + +val namespace_task : + bsc:string -> + runtime:string -> + build_dir:string -> + ocaml_dir:string -> + entry:string option -> + package_dirty:bool -> + force:bool -> + string -> + Source.module_ list -> + Compiler_scheduler.namespace_task option + +val compile_job : + bsc:string -> + build_dir:string -> + config:Config.t -> + common_args:string list -> + Source.module_ -> + source_kind:Source.source_kind -> + string -> + Process.job + +val post_build_tasks : + Config.t -> string -> Compiler_scheduler.post_build_task list + +val publish : + build_dir:string -> + ocaml_dir:string -> + is_local:bool -> + config:Config.t -> + source_kind:Source.source_kind -> + string -> + Process.result -> + Compiler_scheduler.publish_result diff --git a/rewatch-ocaml/compiler_scheduler.ml b/rewatch-ocaml/compiler_scheduler.ml new file mode 100644 index 00000000000..15636aaf792 --- /dev/null +++ b/rewatch-ocaml/compiler_scheduler.ml @@ -0,0 +1,429 @@ +exception Build_failure of string +exception Module_failed +type cmi_change = Build_state.cmi_change = + | Cmi_changed + | Cmi_unchanged + | Cmi_change_unknown +exception Publication_failure of exn * cmi_change + +type publish_result = {stderr: string; cmi_change: cmi_change} +type namespace_task = { + job: Process.job; + publish: Process.result -> publish_result; +} +type post_build_task = {output: string; task: Process.task} + +type phase = + | Start + | Interface of string + | Implementation of string + | Post_build of {output: string; remaining: post_build_task list} + | Done + +type publication = + | Published of publish_result + | Failed_after_cmi_publication of {error: exn; cmi_change: cmi_change} + +type recorded_publication = + | No_publication + | Publication_succeeded of string + | Publication_failed of string + +let capture_publication publish = + try Published (publish ()) with + | Publication_failure (error, cmi_change) -> + Failed_after_cmi_publication {error; cmi_change} + | error -> + Failed_after_cmi_publication {error; cmi_change = Cmi_change_unknown} + +type scheduled_module = { + key: string; + dependencies: string list; + source: Source.module_; + state: Build_state.module_; + cmi_path: string; + publication: publication option Atomic.t; + prepare: unit -> unit; + compile: source_kind:Source.source_kind -> string -> Process.job; + publish: + source_kind:Source.source_kind -> string -> Process.result -> publish_result; + record_published_outputs: source_kind:Source.source_kind -> string -> unit; + post_build: string -> post_build_task list; + package_root: string; + is_local: bool; + mark_warning: string -> unit; + mutable messages: string list; + mutable phase: phase; +} + +type candidate = { + key: string; + state: Build_state.module_; + warning_paths: string list; + make: unit -> scheduled_module; +} + +type scheduled_item = Module of scheduled_module | Namespace_barrier + +let create ~key ~dependencies ~source ~state ~cmi_path ~prepare ~compile + ~publish ~record_published_outputs ~post_build ~package_root ~is_local + ~mark_warning = + { + key; + dependencies; + source; + state; + cmi_path; + publication = Atomic.make None; + prepare; + compile; + publish; + record_published_outputs; + post_build; + package_root; + is_local; + mark_warning; + messages = []; + phase = Start; + } + +let candidate ~key ~state ~warning_paths ~make = + {key; state; warning_paths; make} + +let candidate_requires_compile candidate = candidate.state.compile_dirty + +let run ~poll ~warning_state ~compile_assets ~build_state ~candidates + ~mark_compiled ~mark_had_warnings ~progress ~compile_step ~namespace_count + ~verbosity = + let dirty_propagation = Hashtbl.create 16 in + let refresh_published_cmi (scheduled : scheduled_module) cmi_change = + Build_state.record_published_cmi ~dirty_propagation build_state + ~compile_assets scheduled.state ~path:scheduled.cmi_path cmi_change + in + let finish_successful_compile (scheduled : scheduled_module) = + let cmt_path = Filename.remove_extension scheduled.cmi_path ^ ".cmt" in + Build_state.record_successful_compile ~compile_assets scheduled.state + ~cmt_path + in + let warning_paths = + candidates |> List.concat_map (fun candidate -> candidate.warning_paths) + in + Warning_state.retain_paths warning_state warning_paths; + if Output.trace_enabled verbosity then + candidates + |> List.filter candidate_requires_compile + |> List.sort (fun first second -> String.compare first.key second.key) + |> List.iter (fun candidate -> + Printf.printf "compile dirty: %s\n%!" candidate.key); + (* The scheduler only needs dirty modules and their transitive dependents. + Dependencies outside that universe already have usable artifacts, while + keeping every module in the subprocess graph makes small edits scale with + the whole project. *) + let candidate_by_key = Hashtbl.create (List.length candidates) in + List.iter + (fun candidate -> Hashtbl.replace candidate_by_key candidate.key candidate) + candidates; + let universe = Hashtbl.create (List.length candidates) in + let reached = Hashtbl.create (List.length candidates) in + let pending = Queue.create () in + let add_to_closure key = + if not (Hashtbl.mem reached key) then ( + Hashtbl.add reached key (); + if Hashtbl.mem candidate_by_key key then Hashtbl.add universe key (); + Queue.add key pending) + in + candidates + |> List.iter (fun candidate -> + if candidate.state.compile_dirty then add_to_closure candidate.key); + while not (Queue.is_empty pending) do + let key = Queue.take pending in + let state = Build_state.find_exn build_state key in + Build_state.String_set.iter add_to_closure state.dependents + done; + let scheduled_modules = + candidates + |> List.filter (fun candidate -> Hashtbl.mem universe candidate.key) + |> List.map (fun candidate -> candidate.make ()) + in + Output.Progress.start progress ~step:compile_step + ~symbol:Platform.build_symbol ~label:"Compiling" + ~total:(namespace_count + List.length scheduled_modules); + for _ = 1 to namespace_count do + Output.Progress.advance progress + done; + let completed_modules = ref 0 in + let scheduled_keys = Hashtbl.copy universe in + Hashtbl.iter + (fun key () -> + match Build_state.find build_state key with + | Some state when state.kind = Build_state.Namespace_map -> + Hashtbl.replace scheduled_keys key () + | Some _ | None -> ()) + reached; + let scheduled_dependencies dependencies = + List.filter (Hashtbl.mem scheduled_keys) dependencies + in + let module_works = + scheduled_modules + |> List.map (fun (scheduled : scheduled_module) -> + Process. + { + key = scheduled.key; + dependencies = scheduled_dependencies scheduled.dependencies; + value = Module scheduled; + }) + in + let namespace_works = + Hashtbl.to_seq_keys scheduled_keys + |> Seq.filter_map (fun key -> + match Build_state.find build_state key with + | Some state when state.kind = Build_state.Namespace_map -> + Some + Process. + { + key; + dependencies = scheduled_dependencies state.dependencies; + value = Namespace_barrier; + } + | Some _ | None -> None) + |> List.of_seq + in + let works = module_works @ namespace_works in + let record_publication (scheduled : scheduled_module) ~source_kind path = + let publication = Atomic.exchange scheduled.publication None in + match publication with + | Some (Published {stderr; cmi_change}) -> + refresh_published_cmi scheduled cmi_change; + scheduled.record_published_outputs ~source_kind path; + Publication_succeeded stderr + | Some (Failed_after_cmi_publication {error; cmi_change}) -> + refresh_published_cmi scheduled cmi_change; + scheduled.record_published_outputs ~source_kind path; + Publication_failed (Printexc.to_string error) + | None -> No_publication + in + let record_result (scheduled : scheduled_module) ~source_kind path result = + let result, publication_error = + match record_publication scheduled ~source_kind path with + | Publication_succeeded stderr -> ({result with Process.stderr}, None) + | Publication_failed message -> (result, Some message) + | No_publication -> (result, None) + in + let message = + match publication_error with + | Some message -> + Warning_state.remove warning_state ~package_root:scheduled.package_root + ~path; + Some message + | None -> + if Process.succeeded result then ( + match result.Process.stderr with + | "" -> + Warning_state.remove warning_state + ~package_root:scheduled.package_root ~path; + None + | warning -> + mark_had_warnings (); + Warning_state.set warning_state ~module_name:scheduled.key + ~package_root:scheduled.package_root ~path ~output:warning; + if scheduled.is_local then scheduled.mark_warning path; + None) + else ( + Warning_state.remove warning_state + ~package_root:scheduled.package_root ~path; + Some (result.Process.stderr ^ result.Process.stdout)) + in + Option.iter + (fun message -> scheduled.messages <- message :: scheduled.messages) + message; + Option.is_none message + in + let compilation_task (scheduled : scheduled_module) ~source_kind path = + let job = scheduled.compile ~source_kind path in + Atomic.set scheduled.publication None; + Process.task job ~on_result:(fun result -> + (if Process.succeeded result then + let publication = + capture_publication (fun () -> + scheduled.publish ~source_kind path result) + in + Atomic.set scheduled.publication (Some publication)); + result) + in + let record_post_build_result (scheduled : scheduled_module) output result = + if Process.succeeded result then ( + if result.Process.stdout <> "" then print_string result.stdout; + if result.stderr <> "" then prerr_string result.stderr; + true) + else + let captured = result.stderr ^ result.stdout in + let message = + Printf.sprintf "js-post-build command failed for %s%s" output + (if captured = "" then "" else "\n" ^ captured) + in + scheduled.messages <- message :: scheduled.messages; + false + in + let invalidate_persistent_freshness (scheduled : scheduled_module) = + let ocaml_dir = Filename.dirname scheduled.cmi_path in + scheduled.source.Source.implementation + :: Option.to_list scheduled.source.Source.interface + |> List.iter (fun source -> + let path = Build_artifacts.published_ast_path ~ocaml_dir source in + File_util.remove_file path; + Compile_assets.refresh_ast compile_assets + ~source:(Filename.concat scheduled.package_root source) + ~path) + in + let complete_module (scheduled : scheduled_module) = + scheduled.phase <- Done; + Output.Progress.advance progress; + if scheduled.messages <> [] then ( + invalidate_persistent_freshness scheduled; + raise Module_failed) + else ( + finish_successful_compile scheduled; + incr completed_modules) + in + let continue_post_build (scheduled : scheduled_module) tasks = + match tasks with + | [] -> + complete_module scheduled; + None + | {output; task} :: remaining -> + scheduled.phase <- Post_build {output; remaining}; + Some task + in + let reconcile_unconsumed_publications () = + List.iter + (fun (scheduled : scheduled_module) -> + let attempt_is_incomplete = + match scheduled.phase with + | Interface _ | Implementation _ | Post_build _ -> true + | Start | Done -> false + in + let source = + match scheduled.phase with + | Interface path -> Some (Source.Interface, path) + | Implementation path -> Some (Source.Implementation, path) + | Start | Post_build _ | Done -> None + in + Option.iter + (fun (source_kind, path) -> + match record_publication scheduled ~source_kind path with + | Publication_failed message -> + scheduled.messages <- message :: scheduled.messages + | No_publication | Publication_succeeded _ -> ()) + source; + if attempt_is_incomplete then invalidate_persistent_freshness scheduled) + scheduled_modules + in + let scheduler_failed = + try + Process.run_dependency_graph ?poll + works + (* Rust's parallel scheduler may already have independent work in + flight when a module fails. Finish all ready, independent work here + so the same diagnostics do not depend on the host's worker count; + dependents of the failed module remain blocked. *) + ~on_failure:(function + | Module_failed -> Process.Continue_independent_work + | _ -> Process.Abort_immediately) + ~next:(fun item result -> + match item with + | Namespace_barrier -> ( + match result with + | None -> None + | Some _ -> + raise + (Project_context.Error + "namespace scheduler barrier produced a process result")) + | Module scheduled -> ( + match (result, scheduled.phase) with + | None, Start -> + if scheduled.state.compile_dirty then ( + mark_compiled (); + scheduled.prepare (); + match scheduled.source.Source.interface with + | Some path -> + Output.Progress.debug progress ~verbosity + ("Compiling interface file: " ^ scheduled.key); + scheduled.phase <- Interface path; + Some + (compilation_task scheduled ~source_kind:Source.Interface + path) + | None -> + let path = scheduled.source.Source.implementation in + Output.Progress.debug progress ~verbosity + ("Compiling file: " ^ scheduled.key); + scheduled.phase <- Implementation path; + Some + (compilation_task scheduled + ~source_kind:Source.Implementation path)) + else ( + scheduled.phase <- Done; + incr completed_modules; + Output.Progress.advance progress; + None) + | Some result, Interface path -> + ignore + (record_result scheduled ~source_kind:Source.Interface path + result); + let path = scheduled.source.Source.implementation in + Output.Progress.debug progress ~verbosity + ("Compiling file: " ^ scheduled.key); + scheduled.phase <- Implementation path; + Some + (compilation_task scheduled ~source_kind:Source.Implementation + path) + | Some result, Implementation path -> + if + record_result scheduled ~source_kind:Source.Implementation path + result + then continue_post_build scheduled (scheduled.post_build path) + else ( + complete_module scheduled; + None) + | Some result, Post_build {output; remaining} -> + if record_post_build_result scheduled output result then + continue_post_build scheduled remaining + else ( + complete_module scheduled; + None) + | None, (Interface _ | Implementation _ | Post_build _ | Done) + | Some _, (Start | Done) -> + raise (Project_context.Error "invalid compiler scheduler state"))); + false + with exn -> ( + reconcile_unconsumed_publications (); + match exn with + | Module_failed -> true + | _ -> raise exn) + in + Output.Progress.finish progress; + Output.trace ~verbosity + (Printf.sprintf "Compiled %d out of %d in the universe" !completed_modules + (List.length scheduled_modules)); + let failures = + scheduled_modules + |> List.sort (fun (first : scheduled_module) second -> + String.compare first.key second.key) + |> List.concat_map (fun scheduled -> + scheduled.messages |> List.rev + |> List.map (fun output -> (scheduled, output))) + in + Warning_state.entries warning_state + |> List.iter (fun entry -> + Compiler_log.append entry.Warning_state.package_root entry.output); + List.iter + (fun ((scheduled : scheduled_module), output) -> + Compiler_log.append scheduled.package_root output) + failures; + match (failures, scheduler_failed) with + | [], false -> () + | [], true -> + raise + (Project_context.Error "compiler scheduler stopped without a diagnostic") + | failures, _ -> + failures |> List.map snd |> String.concat "" |> fun output -> + raise (Build_failure output) diff --git a/rewatch-ocaml/compiler_scheduler.mli b/rewatch-ocaml/compiler_scheduler.mli new file mode 100644 index 00000000000..2d3c31858fe --- /dev/null +++ b/rewatch-ocaml/compiler_scheduler.mli @@ -0,0 +1,73 @@ +exception Build_failure of string + +(* CMI change has an explicit unknown case because a later publication failure + must not erase the fact that an interface may already have become visible. + Unknown therefore invalidates conservatively instead of pretending that the + CMI was unchanged. *) +type cmi_change = Build_state.cmi_change = + | Cmi_changed + | Cmi_unchanged + | Cmi_change_unknown +exception Publication_failure of exn * cmi_change + +type publish_result = {stderr: string; cmi_change: cmi_change} +type namespace_task = { + job: Process.job; + publish: Process.result -> publish_result; +} +type post_build_task = {output: string; task: Process.task} + +(* Publication can fail after the CMI was copied. The partial outcome is kept + so dependents are still invalidated even though the module itself remains + dirty for retry. *) +type publication = + | Published of publish_result + | Failed_after_cmi_publication of {error: exn; cmi_change: cmi_change} + +val capture_publication : (unit -> publish_result) -> publication + +type scheduled_module +type candidate + +val create : + key:string -> + dependencies:string list -> + source:Source.module_ -> + state:Build_state.module_ -> + cmi_path:string -> + prepare:(unit -> unit) -> + compile:(source_kind:Source.source_kind -> string -> Process.job) -> + publish: + (source_kind:Source.source_kind -> + string -> + Process.result -> + publish_result) -> + record_published_outputs:(source_kind:Source.source_kind -> string -> unit) -> + post_build:(string -> post_build_task list) -> + package_root:string -> + is_local:bool -> + mark_warning:(string -> unit) -> + scheduled_module + +val candidate : + key:string -> + state:Build_state.module_ -> + warning_paths:string list -> + make:(unit -> scheduled_module) -> + candidate + +val candidate_requires_compile : candidate -> bool + +val run : + poll:(unit -> unit) option -> + warning_state:Warning_state.t -> + compile_assets:Compile_assets.t -> + build_state:Build_state.t -> + candidates:candidate list -> + mark_compiled:(unit -> unit) -> + mark_had_warnings:(unit -> unit) -> + progress:Output.Progress.t -> + compile_step:string -> + namespace_count:int -> + verbosity:int -> + unit diff --git a/rewatch-ocaml/config.ml b/rewatch-ocaml/config.ml new file mode 100644 index 00000000000..47f97727d06 --- /dev/null +++ b/rewatch-ocaml/config.ml @@ -0,0 +1,436 @@ +include Config_types + +open Config_decode + +let namespace_from_package_name = Config_decode.namespace_from_package_name + +let namespace_name = function + | No_namespace -> None + | Namespace name | Namespace_with_entry {name; entry = _} -> Some name + +let namespace_entry = function + | Namespace_with_entry {name = _; entry} -> Some entry + | No_namespace | Namespace _ -> None + +let namespace_compiler_name = function + | No_namespace -> None + | Namespace name -> Some name + | Namespace_with_entry {name; entry = _} -> Some ("@" ^ name) + +let namespaced_module_name namespace module_name = + match namespace with + | No_namespace -> module_name + | Namespace name -> module_name ^ "-" ^ name + | Namespace_with_entry {name; entry} -> + if entry = module_name then module_name else module_name ^ "-@" ^ name + +let path_in_root root = + let current = Filename.concat root "rescript.json" in + if File_util.exists current then current + else Filename.concat root "bsconfig.json" + +let exists_in_root root = + File_util.exists (Filename.concat root "rescript.json") + || File_util.exists (Filename.concat root "bsconfig.json") + +let source_is_dev (config : t) relative_path = + let canonical path = + try Some (Platform.canonicalize_path path) + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None + in + let source_parent = + Filename.concat config.root relative_path |> Filename.dirname |> canonical + in + match source_parent with + | None -> false + | Some source_parent -> + let comparable = Platform.normalize_path_for_comparison in + List.find_map + (fun (source : source) -> + match canonical (Filename.concat config.root source.dir) with + | None -> None + | Some directory -> + if + comparable source_parent = comparable directory + || source.recurse + && String.starts_with + ~prefix:(Filename.concat directory "" |> comparable) + (comparable source_parent) + then Some source.is_dev + else None) + config.sources + |> Option.value ~default:false + +(* Every package in one invocation must use the root project's effective + compiler options so dependencies expose outputs their consumers can use. *) +let with_root_options (config : t) (root_config : t) = + { + config with + package_specs = root_config.package_specs; + suffix = root_config.suffix; + jsx_args = root_config.jsx_args; + source_map_args = root_config.source_map_args; + source_map_dev = root_config.source_map_dev; + experimental_args = root_config.experimental_args; + gentype_args = + (if config.gentype_args = [] then [] + else + config.gentype_args + @ ["-bs-gentype-bsb-project-root"; root_config.root]); + } + +let load path = + let requested_path = path in + let root = + try Platform.canonicalize_path (Filename.dirname path) with + | Sys_error message -> + fail_read requested_path (strip_read_path requested_path message) + | Unix.Unix_error (error, _, _) -> + fail_read requested_path (Unix.error_message error) + in + let path = Filename.concat root (Filename.basename path) in + (try + match (Unix.stat path).Unix.st_kind with + | Unix.S_DIR -> fail_read path (Unix.error_message Unix.EISDIR) + | Unix.S_REG | Unix.S_CHR | Unix.S_BLK | Unix.S_LNK | Unix.S_FIFO + | Unix.S_SOCK -> + () + with + | Sys_error message -> fail_read path (strip_read_path path message) + | Unix.Unix_error (error, _, _) -> fail_read path (Unix.error_message error)); + let contents = + try File_util.read_file path with + | Sys_error message -> fail_read path (strip_read_path path message) + | Unix.Unix_error (error, _, _) -> fail_read path (Unix.error_message error) + in + let json = + try Yojson.Safe.from_string contents + with Yojson.Json_error message -> fail path ("invalid JSON: " ^ message) + in + let fields = + match json with + | `Assoc fields -> fields + | _ -> fail path "configuration must be an object" + in + reject_duplicate_fields path "configuration" configuration_fields fields; + let name = + match member "name" fields with + | Some value -> string path "name" value + | None -> fail path "missing required field \"name\"" + in + let configured_suffix = + match optional_member "suffix" fields with + | None -> None + | Some value -> Some (string path "suffix" value) + in + let suffix = Option.value configured_suffix ~default:".js" in + let package_specs = + match optional_member "package-specs" fields with + | None -> + [{module_format = Esmodule; in_source = true; suffix = Some ".js"}] + | Some (`List values) -> List.map (parse_package_spec path) values + | Some value -> [parse_package_spec path value] + in + let seen_package_outputs = Hashtbl.create (List.length package_specs) in + List.iter + (fun (spec : package_spec) -> + let effective_suffix = Option.value spec.suffix ~default:suffix in + let key = (effective_suffix, spec.in_source) in + if Hashtbl.mem seen_package_outputs key then + fail path + (Printf.sprintf "Duplicate package-spec suffix %S is not allowed." + effective_suffix); + Hashtbl.add seen_package_outputs key ()) + package_specs; + let namespace_name = + match optional_member "namespace" fields with + | None | Some (`Bool false) -> None + | Some (`Bool true) -> Some (namespace_from_package_name name) + | Some (`String "true") -> Some (namespace_from_package_name name) + | Some (`String value) -> Some (namespace_from_package_name value) + | Some _ -> fail path "field \"namespace\" must be a boolean or string" + in + let namespace = + match (optional_member "namespace-entry" fields, namespace_name) with + | None, None -> No_namespace + | None, Some name -> Namespace name + | Some _, None -> fail path "field \"namespace-entry\" requires a namespace" + | Some value, Some name -> + Namespace_with_entry {name; entry = string path "namespace-entry" value} + in + let compiler_flags = + match (member "compiler-flags" fields, member "bsc-flags" fields) with + | Some _, Some _ -> + fail path "fields \"compiler-flags\" and \"bsc-flags\" cannot both be set" + | Some `Null, None | None, Some `Null -> [] + | Some value, None -> compiler_flags path "compiler-flags" value + | None, Some value -> compiler_flags path "bsc-flags" value + | None, None -> [] + in + let warning_flags = + match optional_member "warnings" fields with + | None -> [] + | Some (`Assoc warning_fields) -> + reject_duplicate_fields path "warnings" Config_decode.warning_fields + warning_fields; + let number = + match optional_member "number" warning_fields with + | None -> [] + | Some value -> ["-w"; string path "number" value] + in + let error = + match optional_member "error" warning_fields with + | Some (`Bool true) -> ["-warn-error"; "A"] + | Some (`String value) -> ["-warn-error"; value] + | None | Some (`Bool false) -> [] + | Some _ -> + fail path "field \"warnings.error\" must be a boolean or string" + in + number @ error + | Some _ -> fail path "field \"warnings\" must be an object" + in + let ppx_flags = + match optional_member "ppx-flags" fields with + | None -> [] + | Some (`List values) -> + List.map + (function + | `String value -> [value] + | `List values -> List.map (string path "ppx-flags") values + | _ -> + fail path "field \"ppx-flags\" entries must be strings or arrays") + values + | Some _ -> fail path "field \"ppx-flags\" must be an array" + in + let jsx_args = + match optional_member "jsx" fields with + | None -> [] + | Some (`Assoc jsx) -> + reject_duplicate_fields path "jsx" jsx_fields jsx; + let version = + match optional_member "version" jsx with + | None -> [] + | Some (`Int 4) -> ["-bs-jsx"; "4"] + | Some _ -> fail path "field \"jsx.version\" must be 4" + in + let module_ = + match optional_member "module" jsx with + | None -> [] + | Some value -> ["-bs-jsx-module"; string path "jsx.module" value] + in + let mode = + match optional_member "mode" jsx with + | None -> [] + | Some (`String (("classic" | "automatic") as value)) -> + ["-bs-jsx-mode"; value] + | Some _ -> + fail path "field \"jsx.mode\" must be \"classic\" or \"automatic\"" + in + let preserve = + match optional_member "preserve" jsx with + | None | Some (`Bool false) -> [] + | Some (`Bool true) -> ["-bs-jsx-preserve"] + | Some _ -> fail path "field \"jsx.preserve\" must be a boolean" + in + (match optional_member "v3-dependencies" jsx with + | None -> () + | Some value -> ignore (strings path "jsx.v3-dependencies" value)); + version @ module_ @ mode @ preserve + | Some _ -> fail path "field \"jsx\" must be an object" + in + let source_map_args, source_map_dev = + match optional_member "sourceMap" fields with + | None -> ([], false) + | Some (`Bool false) -> (["-bs-source-map"; "false"], false) + | Some (`Bool true) -> + fail path + "sourceMap true is unsupported; use an object with enabled and mode \ + fields or false" + | Some (`Assoc options) -> + reject_duplicates path "sourceMap" options; + let mode = + match member "mode" options with + | Some (`String (("linked" | "inline" | "hidden") as value)) -> value + | None -> fail path "sourceMap is missing field \"mode\"" + | Some _ -> + fail path "sourceMap.mode must be one of linked, inline, hidden" + in + let dev_only = + match member "enabled" options with + | Some (`String "always") -> false + | Some (`String "dev") -> true + | None -> fail path "sourceMap is missing field \"enabled\"" + | Some _ -> fail path "sourceMap.enabled must be \"always\" or \"dev\"" + in + let content = + match optional_member "sourcesContent" options with + | None -> [] + | Some (`Bool value) -> + ["-bs-source-map-sources-content"; string_of_bool value] + | Some _ -> + fail path "field \"sourceMap.sourcesContent\" must be a boolean" + in + let root = + match optional_member "sourceRoot" options with + | None -> [] + | Some value -> + ["-bs-source-map-root"; string path "sourceMap.sourceRoot" value] + in + (["-bs-source-map"; mode] @ content @ root, dev_only) + | Some _ -> fail path "field \"sourceMap\" must be false or an object" + in + let experimental_args = + match optional_member "experimental-features" fields with + | None -> [] + | Some (`Assoc features) -> + reject_duplicates path "experimental-features" features; + features + |> List.concat_map (fun (name, value) -> + if name <> "LetUnwrap" then + fail path + (Printf.sprintf + "Unknown experimental feature '%s'. Available features: \ + LetUnwrap" + name); + match value with + | `Bool true -> ["-enable-experimental"; name] + | `Bool false -> [] + | _ -> + fail path + "experimental-features: invalid type: feature values must be \ + booleans") + | Some _ -> + fail path + "Could not read rescript.json: experimental-features: invalid type: \ + expected an object" + in + let sources_defined = Option.is_some (optional_member "sources" fields) in + let sources = parse_sources path fields in + let dependencies = + dependency_alias path "dependencies" "bs-dependencies" fields + in + let dev_dependencies = + dependency_alias path "dev-dependencies" "bs-dev-dependencies" fields + in + let gentype_args = + match optional_member "gentypeconfig" fields with + | None -> [] + | Some value -> + gentype_args path configured_suffix + (member "package-specs" fields) + dependencies value + in + let js_post_build = + match optional_member "js-post-build" fields with + | None -> None + | Some (`Assoc fields) -> ( + reject_duplicate_fields path "js-post-build" js_post_build_fields fields; + match member "cmd" fields with + | Some value -> Some (string path "js-post-build.cmd" value) + | None -> fail path "field \"js-post-build\" is missing \"cmd\"") + | Some _ -> fail path "field \"js-post-build\" must be an object" + in + let allowed_dependents = + match optional_member "allowed-dependents" fields with + | None -> None + | Some value -> Some (strings path "allowed-dependents" value) + in + let features = + match optional_member "features" fields with + | None -> [] + | Some (`Assoc values) -> + reject_duplicates path "features" values; + List.map + (fun (name, value) -> (name, strings path "features" value)) + values + | Some _ -> fail path "field \"features\" must be an object" + in + let unsupported = Config_decode.unsupported_fields fields in + let deprecated = + (if Filename.basename path = "bsconfig.json" then + [" - filename 'bsconfig.json' — rename to 'rescript.json'"] + else []) + @ ([ + ("bs-dependencies", "dependencies"); + ("bs-dev-dependencies", "dev-dependencies"); + ("bsc-flags", "compiler-flags"); + ] + |> List.filter_map (fun (field, replacement) -> + if Option.is_some (member field fields) then + Some + (Printf.sprintf " - field '%s' — use '%s' instead" field + replacement) + else None)) + @ + match member "package-specs" fields with + | Some value -> + [ + ("cjs", " - module 'cjs' in package-specs — use 'commonjs' instead"); + ("es6", " - module 'es6' in package-specs — use 'esmodule' instead"); + ] + |> List.filter_map (fun (alias, message) -> + if package_specs_use_alias alias value then Some message else None) + | None -> [] + in + let deprecation_diagnostics = + if deprecated = [] then [] + else + [ + Printf.sprintf + "\n\ + Package '%s' uses deprecated config (support will be removed in a \ + future version):\n\ + %s" + name + (String.concat "\n" deprecated); + ] + in + let diagnostics = + deprecation_diagnostics + @ (unsupported + |> List.map (fun field -> + Printf.sprintf + "The field '%s' found in the package config of '%s' is not \ + supported by ReScript 12's new build system." + field name)) + @ (unknown_fields fields + |> List.map (fun field -> + Printf.sprintf + "Unknown field '%s' found in the package config of '%s'. This \ + option will be ignored." + field name)) + in + { + path; + root; + file_hash = Digest.string contents |> Digest.to_hex; + name; + sources; + sources_defined; + dependencies; + dev_dependencies; + compiler_flags; + package_specs; + suffix; + namespace; + features; + warning_flags; + ppx_flags; + jsx_args; + source_map_args; + source_map_dev; + experimental_args; + gentype_args; + js_post_build; + allowed_dependents; + deprecation_diagnostics; + diagnostics; + } + +let load_root root = load (path_in_root root) + +let package_spec_suffix (config : t) (spec : package_spec) = + Option.value spec.suffix ~default:config.suffix +let module_format_name = function + | Esmodule -> "esmodule" + | Commonjs -> "commonjs" diff --git a/rewatch-ocaml/config.mli b/rewatch-ocaml/config.mli new file mode 100644 index 00000000000..36299039226 --- /dev/null +++ b/rewatch-ocaml/config.mli @@ -0,0 +1,14 @@ +include module type of Config_types + +val namespace_name : namespace -> string option +val namespace_entry : namespace -> string option +val namespace_compiler_name : namespace -> string option +val namespaced_module_name : namespace -> string -> string +val path_in_root : string -> string +val exists_in_root : string -> bool +val source_is_dev : t -> string -> bool +val with_root_options : t -> t -> t +val load : string -> t +val load_root : string -> t +val package_spec_suffix : t -> package_spec -> string +val module_format_name : module_format -> string diff --git a/rewatch-ocaml/config_decode.ml b/rewatch-ocaml/config_decode.ml new file mode 100644 index 00000000000..0cef22c443c --- /dev/null +++ b/rewatch-ocaml/config_decode.ml @@ -0,0 +1,402 @@ +open Config_types + +exception Error = Config_types.Error + +let fail path message = raise (Error (Printf.sprintf "%s: %s" path message)) + +let fail_read path message = + raise (Error (Printf.sprintf "Could not read '%s': %s" path message)) + +let strip_read_path path message = + String_util.strip_prefix ~prefix:(path ^ ": ") message + +let member name fields = List.assoc_opt name fields + +let optional_member name fields = + match member name fields with + | None | Some `Null -> None + | value -> value + +type field_policy = {known: string list; duplicate_checked: string list} + +let reject_all_duplicates known = {known; duplicate_checked = known} + +let unsupported_configuration_fields = + [ + "ignored-dirs"; + "generators"; + "cut-generators"; + "pp-flags"; + "entries"; + "bs-external-includes"; + ] + +let configuration_duplicate_fields = + [ + "name"; + "sources"; + "package-specs"; + "warnings"; + "suffix"; + "dependencies"; + "bs-dependencies"; + "dev-dependencies"; + "bs-dev-dependencies"; + "features"; + "ppx-flags"; + "compiler-flags"; + "bsc-flags"; + "namespace"; + "jsx"; + "sourceMap"; + "experimental-features"; + "gentypeconfig"; + "js-post-build"; + "editor"; + "reanalyze"; + "namespace-entry"; + "allowed-dependents"; + ] + +let configuration_fields = + { + known = configuration_duplicate_fields @ unsupported_configuration_fields; + duplicate_checked = configuration_duplicate_fields; + } + +let warning_fields = reject_all_duplicates ["number"; "error"] + +let jsx_fields = + reject_all_duplicates + ["version"; "module"; "mode"; "v3-dependencies"; "preserve"] + +let gentype_fields = + reject_all_duplicates + [ + "module"; + "moduleResolution"; + "exportInterfaces"; + "generatedFileExtension"; + "shims"; + "debug"; + ] + +let js_post_build_fields = reject_all_duplicates ["cmd"] +let dependency_fields = reject_all_duplicates ["name"; "features"] + +let source_fields = reject_all_duplicates ["dir"; "subdirs"; "type"; "feature"] + +let package_spec_fields = + reject_all_duplicates ["module"; "in-source"; "suffix"] + +let reject_duplicate_fields path context policy fields = + let seen = Hashtbl.create (List.length fields) in + List.iter + (fun (name, _) -> + if List.mem name policy.duplicate_checked then + if Hashtbl.mem seen name then + fail path (Printf.sprintf "duplicate field %S in %s" name context) + else Hashtbl.add seen name ()) + fields + +let reject_duplicates path context fields = + let seen = Hashtbl.create (List.length fields) in + List.iter + (fun (name, _) -> + if Hashtbl.mem seen name then + fail path (Printf.sprintf "duplicate field %S in %s" name context) + else Hashtbl.add seen name ()) + fields + +let string path field = function + | `String value -> value + | _ -> fail path (Printf.sprintf "field %S must be a string" field) + +let bool path field = function + | `Bool value -> value + | _ -> fail path (Printf.sprintf "field %S must be a boolean" field) + +let strings path field = function + | `List values -> List.map (string path field) values + | _ -> fail path (Printf.sprintf "field %S must be an array of strings" field) + +let namespace_from_package_name name = + let buffer = Buffer.create (String.length name) in + let capitalize = ref true in + String.iter + (fun character -> + match character with + | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_' -> + Buffer.add_char buffer + (if !capitalize then Char.uppercase_ascii character else character); + capitalize := false + | '/' | '-' -> capitalize := true + | _ -> ()) + name; + Buffer.contents buffer + +let compiler_flags path field = function + | `List values -> + values + |> List.concat_map (function + | `String value -> + String.split_on_char ' ' value |> List.filter (( <> ) "") + | `List values -> + values + |> List.concat_map (fun value -> + string path field value |> String.split_on_char ' ' + |> List.filter (( <> ) "")) + | _ -> + fail path + (Printf.sprintf "field %S entries must be strings or arrays" field)) + | _ -> fail path (Printf.sprintf "field %S must be an array" field) + +let dependency_name path = function + | `String value -> {name = value; features = None} + | `Assoc fields -> ( + reject_duplicate_fields path "dependency" dependency_fields fields; + match member "name" fields with + | Some value -> + let features = + match optional_member "features" fields with + | None -> None + | Some value -> Some (strings path "features" value) + in + {name = string path "name" value; features} + | None -> fail path "dependency object is missing field \"name\"") + | _ -> fail path "dependency must be a string or object" + +let parse_dependencies path field fields = + match optional_member field fields with + | None -> [] + | Some (`List values) -> List.map (dependency_name path) values + | Some _ -> fail path (Printf.sprintf "field %S must be an array" field) + +let dependency_alias path modern legacy fields = + match (member modern fields, member legacy fields) with + | Some _, Some _ -> + fail path + (Printf.sprintf "fields %S and %S cannot both be set" modern legacy) + | Some _, None -> parse_dependencies path modern fields + | None, Some _ -> parse_dependencies path legacy fields + | None, None -> [] + +let rec sources_of_json path inherited_dir forced_dev inherited_feature = + function + | `String dir -> + [ + { + dir = Filename.concat inherited_dir dir; + recurse = false; + is_dev = Option.value forced_dev ~default:false; + feature = inherited_feature; + }; + ] + | `Assoc fields -> + reject_duplicate_fields path "source" source_fields fields; + let dir = + match member "dir" fields with + | Some value -> Filename.concat inherited_dir (string path "dir" value) + | None -> fail path "source object is missing field \"dir\"" + in + let declared_dev = + match optional_member "type" fields with + | None -> false + | Some (`String "dev") -> true + | Some (`String _) -> false + | Some _ -> fail path "source field \"type\" must be a string" + in + let is_dev = Option.value forced_dev ~default:declared_dev in + let feature = + match optional_member "feature" fields with + | None -> inherited_feature + | Some value -> Some (string path "feature" value) + in + let recurse, children = + match optional_member "subdirs" fields with + | None -> (false, []) + | Some (`Bool value) -> (value, []) + | Some (`List values) -> + ( false, + List.concat_map + (sources_of_json path dir (Some is_dev) feature) + values ) + | Some _ -> + fail path "source field \"subdirs\" must be a boolean or array" + in + {dir; recurse; is_dev; feature} :: children + | _ -> fail path "source must be a string or object" + +let parse_sources path fields = + match optional_member "sources" fields with + | None -> [] + | Some (`List values) -> + List.concat_map (sources_of_json path "" None None) values + | Some value -> sources_of_json path "" None None value + +let nested_unknown_fields parent supported = function + | `Assoc fields -> + fields + |> List.filter_map (fun (name, _) -> + if List.mem name supported then None + else Some (Printf.sprintf "%s.?.%s" parent name)) + | _ -> [] + +let unknown_fields fields = + fields + |> List.concat_map (fun (name, value) -> + match name with + | "warnings" -> nested_unknown_fields name warning_fields.known value + | "jsx" -> nested_unknown_fields name jsx_fields.known value + | "gentypeconfig" -> nested_unknown_fields name gentype_fields.known value + | "js-post-build" -> + nested_unknown_fields name js_post_build_fields.known value + | _ -> if List.mem name configuration_fields.known then [] else [name]) + +let unsupported_fields fields = + unsupported_configuration_fields + |> List.filter (fun field -> Option.is_some (member field fields)) + +let parse_package_spec path = function + | `Assoc fields -> + reject_duplicate_fields path "package-specs entry" package_spec_fields + fields; + let module_format = + match member "module" fields with + | Some (`String ("esmodule" | "es6")) -> Esmodule + | Some (`String ("commonjs" | "cjs")) -> Commonjs + | None -> fail path "package-specs entry is missing field \"module\"" + | Some value -> + fail path + (Printf.sprintf "unsupported package module %S" + (Yojson.Safe.to_string value)) + in + let in_source = + match member "in-source" fields with + | None -> true + | Some value -> bool path "in-source" value + in + let suffix = + match optional_member "suffix" fields with + | None -> None + | Some value -> Some (string path "suffix" value) + in + {module_format; in_source; suffix} + | _ -> fail path "package-specs entries must be objects" + +let package_specs_use_alias alias = function + | `Assoc fields -> member "module" fields = Some (`String alias) + | `List values -> + List.exists + (function + | `Assoc fields -> member "module" fields = Some (`String alias) + | _ -> false) + values + | _ -> false + +let gentype_args path configured_suffix package_specs_value dependencies = + function + | `Assoc fields -> + reject_duplicate_fields path "gentypeconfig" gentype_fields fields; + let module_ = + match optional_member "module" fields with + | None -> ( + match package_specs_value with + | Some (`Assoc package_spec) -> ( + match member "module" package_spec with + | Some (`String ("esmodule" | "es6")) -> + ["-bs-gentype-module"; "esmodule"] + | Some (`String ("commonjs" | "cjs")) -> + ["-bs-gentype-module"; "commonjs"] + | _ -> []) + | _ -> []) + | Some (`String (("esmodule" | "commonjs") as value)) -> + ["-bs-gentype-module"; value] + | Some _ -> + fail path + "field \"gentypeconfig.module\" must be \"esmodule\" or \"commonjs\"" + in + let module_resolution = + match optional_member "moduleResolution" fields with + | None -> [] + | Some (`String (("node" | "node16" | "bundler") as value)) -> + ["-bs-gentype-module-resolution"; value] + | Some _ -> + fail path "field \"gentypeconfig.moduleResolution\" is invalid" + in + let export_interfaces = + match optional_member "exportInterfaces" fields with + | None | Some (`Bool false) -> [] + | Some (`Bool true) -> ["-bs-gentype-export-interfaces"] + | Some _ -> + fail path "field \"gentypeconfig.exportInterfaces\" must be a boolean" + in + let generated_extension = + match optional_member "generatedFileExtension" fields with + | None -> [] + | Some value -> + [ + "-bs-gentype-generated-extension"; + string path "gentypeconfig.generatedFileExtension" value; + ] + in + let shims = + let pairs = + match member "shims" fields with + | None -> [] + | Some (`Assoc values) -> + reject_duplicates path "gentypeconfig.shims" values; + List.map + (fun (from_, target) -> + (from_, string path "gentypeconfig.shims" target)) + values + | Some (`List values) -> + let pairs = + List.map + (fun value -> + let value = string path "gentypeconfig.shims" value in + match String.index_opt value '=' with + | Some separator -> + let from_ = String.sub value 0 separator |> String.trim in + let target = + String.sub value (separator + 1) + (String.length value - separator - 1) + |> String.trim + in + (from_, target) + | None -> fail path "gentypeconfig.shims entries must contain =") + values + in + reject_duplicates path "gentypeconfig.shims" pairs; + pairs + | Some _ -> + fail path "field \"gentypeconfig.shims\" must be an object or array" + in + pairs |> List.sort compare + |> List.concat_map (fun (from_, target) -> + ["-bs-gentype-shim"; from_ ^ "=" ^ target]) + in + let debug = + match member "debug" fields with + | None -> [] + | Some (`Assoc values) -> + reject_duplicates path "gentypeconfig.debug" values; + values |> List.sort compare + |> List.concat_map (fun (name, value) -> + match value with + | `Bool true -> ["-bs-gentype-debug"; name] + | `Bool false -> [] + | _ -> fail path "gentypeconfig.debug values must be booleans") + | Some _ -> fail path "field \"gentypeconfig.debug\" must be an object" + in + let suffix_args = + match configured_suffix with + | None -> [] + | Some suffix -> ["-bs-gentype-suffix"; suffix] + in + ["-bs-gentype"] @ module_ @ module_resolution @ export_interfaces + @ generated_extension @ suffix_args @ shims @ debug + @ List.concat_map + (fun (dependency : dependency) -> ["-bs-gentype-dep"; dependency.name]) + dependencies + | _ -> fail path "field \"gentypeconfig\" must be an object" diff --git a/rewatch-ocaml/config_decode.mli b/rewatch-ocaml/config_decode.mli new file mode 100644 index 00000000000..ba6f7df4682 --- /dev/null +++ b/rewatch-ocaml/config_decode.mli @@ -0,0 +1,48 @@ +val fail : string -> string -> 'a +val fail_read : string -> string -> 'a +val strip_read_path : string -> string -> string +val member : string -> (string * 'a) list -> 'a option + +val optional_member : + string -> (string * Yojson.Safe.t) list -> Yojson.Safe.t option + +type field_policy + +val configuration_fields : field_policy +val warning_fields : field_policy +val jsx_fields : field_policy +val js_post_build_fields : field_policy + +val reject_duplicate_fields : + string -> string -> field_policy -> (string * 'a) list -> unit + +val reject_duplicates : string -> string -> (string * 'a) list -> unit + +val string : string -> string -> Yojson.Safe.t -> string +val strings : string -> string -> Yojson.Safe.t -> string list + +val namespace_from_package_name : string -> string +val compiler_flags : string -> string -> Yojson.Safe.t -> string list + +val dependency_alias : + string -> + string -> + string -> + (string * Yojson.Safe.t) list -> + Config_types.dependency list + +val parse_sources : + string -> (string * Yojson.Safe.t) list -> Config_types.source list + +val unknown_fields : (string * Yojson.Safe.t) list -> string list +val unsupported_fields : (string * 'a) list -> string list +val parse_package_spec : string -> Yojson.Safe.t -> Config_types.package_spec +val package_specs_use_alias : string -> Yojson.Safe.t -> bool + +val gentype_args : + string -> + string option -> + Yojson.Safe.t option -> + Config_types.dependency list -> + Yojson.Safe.t -> + string list diff --git a/rewatch-ocaml/config_types.ml b/rewatch-ocaml/config_types.ml new file mode 100644 index 00000000000..0f1d7edb86a --- /dev/null +++ b/rewatch-ocaml/config_types.ml @@ -0,0 +1,44 @@ +type module_format = Esmodule | Commonjs +type namespace = + | No_namespace + | Namespace of string + | Namespace_with_entry of {name: string; entry: string} + +type package_spec = { + module_format: module_format; + in_source: bool; + suffix: string option; +} + +type source = {dir: string; recurse: bool; is_dev: bool; feature: string option} + +type dependency = {name: string; features: string list option} + +type t = { + path: string; + root: string; + file_hash: string; + name: string; + sources: source list; + sources_defined: bool; + dependencies: dependency list; + dev_dependencies: dependency list; + compiler_flags: string list; + package_specs: package_spec list; + suffix: string; + namespace: namespace; + features: (string * string list) list; + warning_flags: string list; + ppx_flags: string list list; + jsx_args: string list; + source_map_args: string list; + source_map_dev: bool; + experimental_args: string list; + gentype_args: string list; + js_post_build: string option; + allowed_dependents: string list option; + deprecation_diagnostics: string list; + diagnostics: string list; +} + +exception Error of string diff --git a/rewatch-ocaml/config_types.mli b/rewatch-ocaml/config_types.mli new file mode 100644 index 00000000000..0f1d7edb86a --- /dev/null +++ b/rewatch-ocaml/config_types.mli @@ -0,0 +1,44 @@ +type module_format = Esmodule | Commonjs +type namespace = + | No_namespace + | Namespace of string + | Namespace_with_entry of {name: string; entry: string} + +type package_spec = { + module_format: module_format; + in_source: bool; + suffix: string option; +} + +type source = {dir: string; recurse: bool; is_dev: bool; feature: string option} + +type dependency = {name: string; features: string list option} + +type t = { + path: string; + root: string; + file_hash: string; + name: string; + sources: source list; + sources_defined: bool; + dependencies: dependency list; + dev_dependencies: dependency list; + compiler_flags: string list; + package_specs: package_spec list; + suffix: string; + namespace: namespace; + features: (string * string list) list; + warning_flags: string list; + ppx_flags: string list list; + jsx_args: string list; + source_map_args: string list; + source_map_dev: bool; + experimental_args: string list; + gentype_args: string list; + js_post_build: string option; + allowed_dependents: string list option; + deprecation_diagnostics: string list; + diagnostics: string list; +} + +exception Error of string diff --git a/rewatch-ocaml/dune b/rewatch-ocaml/dune new file mode 100644 index 00000000000..ee71546f9d0 --- /dev/null +++ b/rewatch-ocaml/dune @@ -0,0 +1,94 @@ +(env + (static + (flags + (:standard -ccopt -static)))) + +(rule + (target platform.ml) + (enabled_if + (= %{os_type} Win32)) + (action + (copy platform_windows.ml platform.ml))) + +(rule + (target platform.ml) + (enabled_if + (<> %{os_type} Win32)) + (action + (copy platform_unix.ml platform.ml))) + +(library + (name rewatch_ocaml_lib) + (wrapped false) + (modules + rewatch_version + string_util + source_filter + cli + config_types + config_decode + config + project_context + package_resolution + package_traversal + platform_common + platform + signal_restore + traversal_coverage + process_child + process + source + package_plan + graph + module_graph + package_diagnostics + package_graph + compiler_args + compiler_args_command + compiler_log + ast_header + compiler_process + compiler_scheduler + after_build + file_util + build_lock + build_artifacts + build_freshness + build_session + build_attempt + build_report + build_preparation + package_parse + package_compilation + package_build + clean + compile_assets + build_state + native_watcher + watch_scope + watch_snapshot + watcher + toolchain + compiler_info + source_dirs + warning_state + output + build + format) + (libraries unix threads yojson str spawn cmdliner luv re.perl) + (foreign_stubs + (language c) + (names windows_job_stubs))) + +(executable + (name rescript_ocaml) + (modules rescript_ocaml) + (libraries rewatch_ocaml_lib)) + +; Keep the former scoped test command useful after moving test-only modules out +; of this production directory. + +(alias + (name runtest) + (deps + (alias ../tests/rewatch_ounit_tests/runtest))) diff --git a/rewatch-ocaml/file_util.ml b/rewatch-ocaml/file_util.ml new file mode 100644 index 00000000000..f91f1956c90 --- /dev/null +++ b/rewatch-ocaml/file_util.ml @@ -0,0 +1,259 @@ +let path_of_parts root parts = List.fold_left Filename.concat root parts + +let is_directory path = + match Unix.stat path with + | metadata -> metadata.Unix.st_kind = Unix.S_DIR + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> false + +let ensure_dir path = + (* OCaml's standard library has no recursive directory-creation primitive. + Another process may win a mkdir race, but EEXIST is success only when the + resulting path is a directory. Filename.dirname may return an unavailable + Windows volume root unchanged, so stop at that fixed point and let mkdir + surface the native filesystem error. *) + let mkdir path = + try Unix.mkdir path 0o755 + with Unix.Unix_error (Unix.EEXIST, _, _) as error -> + if not (is_directory path) then raise error + in + let rec loop path = + if path = "" || path = "." || is_directory path then () + else + let parent = Filename.dirname path in + if parent = path then mkdir path + else ( + loop parent; + mkdir path) + in + loop path + +let read_file path = + let descriptor = Unix.openfile path [Unix.O_RDONLY; Unix.O_CLOEXEC] 0 in + let channel = Unix.in_channel_of_descr descriptor in + Fun.protect + ~finally:(fun () -> close_in_noerr channel) + (fun () -> + let expected_size = (Unix.fstat descriptor).Unix.st_size in + let contents = Buffer.create (max 16 expected_size) in + (* A small initial chunk avoids allocating 64 KiB for tiny files such as + the watch lock. The loop still handles streams and files that grow + while they are being read. *) + let chunk = Bytes.create (max 1 (min 65536 (max 4096 expected_size))) in + let rec read () = + match input channel chunk 0 (Bytes.length chunk) with + | 0 -> Buffer.contents contents + | count -> + Buffer.add_subbytes contents chunk 0 count; + read () + in + read ()) + +let is_regular_file path = + match Unix.stat path with + | metadata -> metadata.Unix.st_kind = Unix.S_REG + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> false + +let digest_file path = + let descriptor = Unix.openfile path [Unix.O_RDONLY; Unix.O_CLOEXEC] 0 in + let channel = Unix.in_channel_of_descr descriptor in + Fun.protect + ~finally:(fun () -> close_in_noerr channel) + (fun () -> Digest.channel channel (-1)) + +let with_output_channel channel write = + try + let result = write channel in + close_out channel; + result + with exn -> + close_out_noerr channel; + raise exn + +let write_file path contents = + let channel = open_out_bin path in + with_output_channel channel (fun channel -> output_string channel contents) + +let append_file path contents = + let channel = + open_out_gen [Open_wronly; Open_append; Open_binary] 0o644 path + in + with_output_channel channel (fun channel -> output_string channel contents) + +let directory_entries path = + let directory = Unix.opendir path in + Fun.protect + ~finally:(fun () -> Unix.closedir directory) + (fun () -> + let rec read entries = + match Unix.readdir directory with + | "." | ".." -> read entries + | name -> read (name :: entries) + | exception End_of_file -> List.rev entries + in + read []) + +let write_file_atomic ?(ensure_parent = true) ?perm path contents = + if ensure_parent then ensure_dir (Filename.dirname path); + (* A temporary file must have a cleanup owner before a watch signal can + interrupt the command. Publishing is also signal-deferred so the final + path always names either the previous complete file or the replacement. *) + let creation_signals = Signal_restore.create ~defer:true in + let temporary = ref None in + let remove_temporary path = + try Sys.remove path with Sys_error _ | Unix.Unix_error _ -> () + in + Fun.protect + ~finally:(fun () -> Option.iter remove_temporary !temporary) + (fun () -> + let candidate, perm = + Signal_restore.protect creation_signals (fun () -> + let perm = + match perm with + | Some _ as perm -> perm + | None -> ( + match Unix.stat path with + | metadata -> Some metadata.Unix.st_perm + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) + -> + None) + in + let candidate = + Filename.temp_file ~temp_dir:(Filename.dirname path) + ".rewatch-write-" ".tmp" + in + temporary := Some candidate; + (candidate, perm)) + in + Option.iter (Unix.chmod candidate) perm; + write_file candidate contents; + let publish_signals = Signal_restore.create ~defer:true in + Signal_restore.protect publish_signals (fun () -> + Sys.rename candidate path; + temporary := None)) + +(* Callers that already created the destination directory may skip that work, + avoiding repeated metadata probes when publishing many files. *) +let copy_existing_file ~ensure_parent source destination = + if ensure_parent then ensure_dir (Filename.dirname destination); + let input_descriptor = + Unix.openfile source [Unix.O_RDONLY; Unix.O_CLOEXEC] 0 + in + let input_channel = Unix.in_channel_of_descr input_descriptor in + Fun.protect + ~finally:(fun () -> close_in_noerr input_channel) + (fun () -> + let output_channel = open_out_bin destination in + with_output_channel output_channel (fun output_channel -> + let buffer = Bytes.create 65_536 in + let rec copy () = + let count = input input_channel buffer 0 (Bytes.length buffer) in + if count > 0 then ( + output output_channel buffer 0 count; + copy ()) + in + copy ())) + +let copy_optional_existing_file ?(ensure_parent = true) source destination = + try copy_existing_file ~ensure_parent source destination + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> ( + try Unix.unlink destination + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> ()) + +let stat_opt path = + try Some (Unix.stat path) + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None + +let exists path = Option.is_some (stat_opt path) + +let files_equal first second = + match stat_opt first with + | None -> false + | Some first_stat -> ( + match stat_opt second with + | None -> false + | Some second_stat -> + first_stat.Unix.st_size = second_stat.Unix.st_size + && + let first_descriptor = + Unix.openfile first [Unix.O_RDONLY; Unix.O_CLOEXEC] 0 + in + let first_channel = Unix.in_channel_of_descr first_descriptor in + Fun.protect + ~finally:(fun () -> close_in_noerr first_channel) + (fun () -> + let second_descriptor = + Unix.openfile second [Unix.O_RDONLY; Unix.O_CLOEXEC] 0 + in + let second_channel = Unix.in_channel_of_descr second_descriptor in + Fun.protect + ~finally:(fun () -> close_in_noerr second_channel) + (fun () -> + let buffer_size = 65_536 in + let first_buffer = Bytes.create buffer_size in + let second_buffer = Bytes.create buffer_size in + let rec read_chunk channel buffer offset = + if offset = Bytes.length buffer then offset + else + match + input channel buffer offset (Bytes.length buffer - offset) + with + | 0 -> offset + | count -> read_chunk channel buffer (offset + count) + in + let equal_prefix length = + let index = ref 0 in + while + !index < length + && Bytes.get first_buffer !index + = Bytes.get second_buffer !index + do + incr index + done; + !index = length + in + let rec loop () = + let first_count = read_chunk first_channel first_buffer 0 in + let second_count = read_chunk second_channel second_buffer 0 in + first_count = second_count + && (first_count = 0 || (equal_prefix first_count && loop ())) + in + loop ()))) + +let copy_file_if_different ?(ensure_parent = true) source destination = + let changed = not (files_equal source destination) in + if changed then copy_existing_file ~ensure_parent source destination; + changed + +let modification_time path = + stat_opt path |> Option.map (fun metadata -> metadata.Unix.st_mtime) + +let remove_file path = + try Unix.unlink path + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> () + +let rec remove_tree path = + match (Unix.lstat path).Unix.st_kind with + | Unix.S_DIR -> + directory_entries path + |> List.iter (fun name -> remove_tree (Filename.concat path name)); + Unix.rmdir path + | _ -> Unix.unlink path + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> () + +let remove_file_best_effort path = + try remove_file path with Sys_error _ | Unix.Unix_error _ -> () + +let rec files_under directory = + match (Unix.lstat directory).Unix.st_kind with + | Unix.S_DIR -> + directory_entries directory + |> List.concat_map (fun name -> + files_under (Filename.concat directory name)) + (* Following links during recursion could leave the requested tree or enter + a cycle. Follow one only to omit dangling links from the result. *) + | Unix.S_LNK -> ( + match Unix.stat directory with + | _ -> [directory] + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> []) + | _ -> [directory] + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> [] diff --git a/rewatch-ocaml/file_util.mli b/rewatch-ocaml/file_util.mli new file mode 100644 index 00000000000..1a98e579200 --- /dev/null +++ b/rewatch-ocaml/file_util.mli @@ -0,0 +1,26 @@ +val path_of_parts : string -> string list -> string +val is_directory : string -> bool +val ensure_dir : string -> unit +val read_file : string -> string +val is_regular_file : string -> bool +val digest_file : string -> Digest.t +val write_file : string -> string -> unit +val append_file : string -> string -> unit +val directory_entries : string -> string list + +val write_file_atomic : + ?ensure_parent:bool -> ?perm:int -> string -> string -> unit + +val copy_existing_file : ensure_parent:bool -> string -> string -> unit + +val copy_optional_existing_file : + ?ensure_parent:bool -> string -> string -> unit + +val exists : string -> bool +val files_equal : string -> string -> bool +val copy_file_if_different : ?ensure_parent:bool -> string -> string -> bool +val modification_time : string -> float option +val remove_file : string -> unit +val remove_tree : string -> unit +val remove_file_best_effort : string -> unit +val files_under : string -> string list diff --git a/rewatch-ocaml/format.ml b/rewatch-ocaml/format.ml new file mode 100644 index 00000000000..158ff8f381a --- /dev/null +++ b/rewatch-ocaml/format.ml @@ -0,0 +1,229 @@ +exception Error of string + +let strip_path path message = + String_util.strip_prefix ~prefix:(path ^ ": ") message + +let with_file_error ~action path f = + try f () with + | Sys_error message -> + raise + (Error + (Printf.sprintf "Could not %s %s: %s" action path + (strip_path path message))) + | Unix.Unix_error (error, _, _) -> + raise + (Error + (Printf.sprintf "Could not %s %s: %s" action path + (Unix.error_message error))) + +let read_file path = + with_file_error ~action:"read file" path (fun () -> File_util.read_file path) + +let write_file path contents = + with_file_error ~action:"write formatted file" path (fun () -> + (* Formatting changes the contents of a user-owned file. Writing through + its existing inode preserves symlinks, hard links, ownership, ACLs, and + extended attributes that replacing the directory entry could lose. *) + File_util.write_file path contents) + +let bsc () = + try Toolchain.bsc () with Toolchain.Error message -> raise (Error message) + +type discovered_package = {config: Config.t; files: string list} + +let package_sources (package : discovered_package) = + package.files |> List.map (Filename.concat package.config.root) + +(* Validate the complete package graph before selecting the local files that + format owns. Scan it with the effective feature selections so dependency + diagnostics and the eventual local file set cannot drift apart. *) +let discover_package_graph (current : Config.t) = + let resolution = Package_resolution.create current in + Package_diagnostics.validate_metadata current; + let graph = + Package_traversal.discover ~root_config:current ~prod:false ~features:None + ~resolution + in + graph.packages + |> List.map (fun (package : Package_traversal.package) -> + let config = package.config in + let is_local = package.is_local in + Package_diagnostics.report_missing_sources + ~is_root:(config.root = current.root) + config; + let features = + if config.root = current.root then None + else + match Package_traversal.find_feature_selection graph config.root with + | None | Some Package_traversal.All_features -> None + | Some (Package_traversal.Selected_features requested) -> + (try ignore (Source.resolve_active_features config requested) + with Source.Error message -> + raise + (Error + (Printf.sprintf "Invalid features for package '%s': %s" + config.name message))); + Some requested + in + let files = + Source.discover_files config + ~prod:(Package_traversal.source_discovery_prod ~prod:false ~is_local) + ~features ~filter:None + ~on_missing:(Package_diagnostics.report_missing_source_folder config) + in + {config; files}) + +let files_in_scope () = + let current_directory = Sys.getcwd () in + let current = + try Config.load_root current_directory + with Config.Error message -> + raise + (Error + (Printf.sprintf "Could not read rescript.json at %s: %s" + current_directory message)) + in + let listed_by_parent = + match + Project_context.nearest_config_path (Filename.dirname current.root) + with + | None -> false + | Some path -> + let parent = Config.load path in + List.exists + (fun (dependency : Config.dependency) -> dependency.name = current.name) + (Package_traversal.requests ~prod:false ~is_local:true parent + |> List.map (fun request -> request.Package_traversal.declaration)) + in + let packages = discover_package_graph current in + let resolution = Package_resolution.create current in + let roots_in_scope = + if listed_by_parent then [current.root] + else + current.root + :: (Package_traversal.requests ~prod:false ~is_local:true current + |> List.map (fun request -> request.Package_traversal.declaration) + |> List.filter_map (fun (dependency : Config.dependency) -> + match + Package_resolution.dependency_path resolution + ~package_root:current.root dependency.name + with + | Some directory + when Package_resolution.is_local resolution directory -> + Some directory + | Some _ | None -> None)) + in + packages + |> List.filter (fun package -> + List.exists (( = ) package.config.root) roots_in_scope) + |> List.concat_map package_sources + |> List.sort_uniq String.compare + +let formatting_error target stderr = + Printf.sprintf "Error formatting %s: %s" target stderr + +let formatted ?poll ~bsc ~target path = + let result = Process.run ?poll ~cwd:(Sys.getcwd ()) bsc ["-format"; path] in + if not (Process.succeeded result) then + raise (Error (formatting_error target result.stderr)); + result.stdout + +let format_check_summary = function + | 1 -> "The file listed above needs formatting" + | count -> Printf.sprintf "The %d files listed above need formatting" count + +let format_files_with_bsc ?max_jobs ?poll ~bsc ~check files = + let cwd = Sys.getcwd () in + let incorrect = ref 0 in + let works = + files + |> List.mapi (fun index path -> + Process. + {key = Printf.sprintf "%08d" index; dependencies = []; value = path}) + in + let next path = function + | None -> + Some (Process.task Process.{program = bsc; args = ["-format"; path]; cwd}) + | Some result -> + if not (Process.succeeded result) then + raise (Error (formatting_error path result.stderr)); + let original = read_file path in + if original <> result.stdout then + if check then ( + incr incorrect; + prerr_endline ("[format check] " ^ path)) + else write_file path result.stdout; + None + in + Process.run_dependency_graph ?max_jobs ?poll works ~next; + if !incorrect > 0 then ( + prerr_endline (format_check_summary !incorrect); + raise (Error "Formatting check failed")) + +type stdin_read_result = Stdin_contents of string | Stdin_error of exn + +let read_stdin_interruptibly ?poll () = + let result = Atomic.make None in + let reader = + Thread.create + (fun () -> + let value = + try + let buffer = Buffer.create 4096 in + let bytes = Bytes.create 65536 in + let rec read () = + match input stdin bytes 0 (Bytes.length bytes) with + | 0 -> Stdin_contents (Buffer.contents buffer) + | count -> + Buffer.add_subbytes buffer bytes 0 count; + read () + in + read () + with exn -> Stdin_error exn + in + Atomic.set result (Some value)) + () + in + let rec await () = + Option.iter (fun poll -> poll ()) poll; + match Atomic.get result with + | Some value -> + Thread.join reader; + value + | None -> + Unix.sleepf 0.02; + await () + in + match await () with + | Stdin_contents contents -> contents + | Stdin_error exn -> raise exn + +let format_stdin ?poll extension = + if extension <> ".res" && extension <> ".resi" then + raise (Error "--stdin must be .res or .resi"); + let bsc = bsc () in + (* The temporary pathname needs a cleanup owner before termination can + interrupt the command, otherwise an early signal can leave it behind. *) + let deferred_signals = Signal_restore.create ~defer:true in + let temporary = ref None in + let remove_temporary () = + Option.iter + (fun path -> try Sys.remove path with Sys_error _ -> ()) + !temporary + in + try + let path = Filename.temp_file "rescript-format-" extension in + temporary := Some path; + Fun.protect ~finally:remove_temporary (fun () -> + Signal_restore.restore deferred_signals; + let contents = read_stdin_interruptibly ?poll () in + File_util.write_file path contents; + print_string (formatted ?poll ~bsc ~target:"stdin" path)) + with exn -> + remove_temporary (); + raise (Signal_restore.exception_after_restore deferred_signals exn) + +let run_files ?poll ~check paths = + let bsc = bsc () in + let files = if paths = [] then files_in_scope () else paths in + format_files_with_bsc ?poll ~bsc ~check files diff --git a/rewatch-ocaml/format.mli b/rewatch-ocaml/format.mli new file mode 100644 index 00000000000..a1c4b9a2f8e --- /dev/null +++ b/rewatch-ocaml/format.mli @@ -0,0 +1,16 @@ +exception Error of string + +val write_file : string -> string -> unit +val formatting_error : string -> string -> string +val format_check_summary : int -> string + +val format_files_with_bsc : + ?max_jobs:int -> + ?poll:(unit -> unit) -> + bsc:string -> + check:bool -> + string list -> + unit + +val format_stdin : ?poll:(unit -> unit) -> string -> unit +val run_files : ?poll:(unit -> unit) -> check:bool -> string list -> unit diff --git a/rewatch-ocaml/graph.ml b/rewatch-ocaml/graph.ml new file mode 100644 index 00000000000..90655a6a610 --- /dev/null +++ b/rewatch-ocaml/graph.ml @@ -0,0 +1,246 @@ +type validation = + | Replace_duplicates_and_ignore_unknown + | Reject_invalid of { + duplicate_node: string -> exn; + unknown_dependency: node:string -> dependency:string -> exn; + } + +type 'a index = { + nodes_by_name: (string, 'a) Hashtbl.t; + dependencies_by_name: (string, string list) Hashtbl.t; + dependents_by_name: (string, string list) Hashtbl.t; +} + +let create_index nodes ~name ~deps ~validation = + let nodes_by_name = Hashtbl.create (List.length nodes) in + List.iter + (fun node -> + let node_name = name node in + match Hashtbl.find_opt nodes_by_name node_name with + | None -> Hashtbl.add nodes_by_name node_name node + | Some _ -> ( + match validation with + | Replace_duplicates_and_ignore_unknown -> + Hashtbl.replace nodes_by_name node_name node + | Reject_invalid {duplicate_node; unknown_dependency = _} -> + raise (duplicate_node node_name))) + nodes; + let dependencies_by_name = Hashtbl.create (Hashtbl.length nodes_by_name) in + let dependents_by_name = Hashtbl.create (Hashtbl.length nodes_by_name) in + Hashtbl.iter + (fun node_name node -> + let dependencies = + deps node + |> List.sort_uniq String.compare + |> List.filter (fun dependency -> + if Hashtbl.mem nodes_by_name dependency then true + else + match validation with + | Replace_duplicates_and_ignore_unknown -> false + | Reject_invalid {duplicate_node = _; unknown_dependency} -> + raise (unknown_dependency ~node:node_name ~dependency)) + in + Hashtbl.add dependencies_by_name node_name dependencies; + List.iter + (fun dependency -> + let dependents = + Hashtbl.find_opt dependents_by_name dependency + |> Option.value ~default:[] + in + Hashtbl.replace dependents_by_name dependency (node_name :: dependents)) + dependencies) + nodes_by_name; + {nodes_by_name; dependencies_by_name; dependents_by_name} + +let node_count index = Hashtbl.length index.nodes_by_name +let find_node index name = Hashtbl.find index.nodes_by_name name + +let dependencies index name = + Hashtbl.find_opt index.dependencies_by_name name |> Option.value ~default:[] + +let dependents index name = + Hashtbl.find_opt index.dependents_by_name name |> Option.value ~default:[] + +let dependency_count index name = List.length (dependencies index name) + +let cycle_blocked_nodes nodes ~name ~deps = + let index = + create_index nodes ~name ~deps + ~validation:Replace_duplicates_and_ignore_unknown + in + let pending = Hashtbl.create (node_count index) in + Hashtbl.iter + (fun node_name _ -> + Hashtbl.add pending node_name (dependency_count index node_name)) + index.nodes_by_name; + let ready = Queue.create () in + Hashtbl.iter (fun key count -> if count = 0 then Queue.add key ready) pending; + let removed = Hashtbl.create (node_count index) in + while not (Queue.is_empty ready) do + let key = Queue.take ready in + Hashtbl.replace removed key (); + dependents index key + |> List.iter (fun dependent -> + let count = Hashtbl.find pending dependent - 1 in + Hashtbl.replace pending dependent count; + if count = 0 then Queue.add dependent ready) + done; + List.filter (fun node -> not (Hashtbl.mem removed (name node))) nodes + +let canonical_cycle cycle = + let rec without_last = function + | [] | [_] -> [] + | value :: rest -> value :: without_last rest + in + let nodes = without_last cycle in + match nodes with + | [] -> cycle + | first :: rest -> + let smallest = List.fold_left min first rest in + let rec split prefix = function + | [] -> nodes + | head :: tail as suffix -> + if head = smallest then suffix @ List.rev prefix + else split (head :: prefix) tail + in + let canonical = split [] nodes in + canonical @ [smallest] + +let shortest_cycle_in_index index = + let names = + index.nodes_by_name |> Hashtbl.to_seq_keys |> List.of_seq + |> List.sort String.compare + in + let edges = Hashtbl.create (Hashtbl.length index.dependencies_by_name) in + List.iter + (fun name -> + List.iter + (fun dependency -> Hashtbl.replace edges (name, dependency) ()) + (dependencies index name)) + names; + match List.find_opt (fun name -> Hashtbl.mem edges (name, name)) names with + | Some name -> Some [name; name] + | None -> ( + let two_node_cycle = + names + |> List.find_map (fun name -> + dependencies index name + |> List.filter (fun dependency -> name < dependency) + |> List.sort String.compare + |> List.find_map (fun dependency -> + if Hashtbl.mem edges (dependency, name) then + Some [name; dependency; name] + else None)) + in + match two_node_cycle with + | Some _ as cycle -> cycle + | None -> + let next_index = ref 0 in + let indices = Hashtbl.create (List.length names) in + let lowlinks = Hashtbl.create (List.length names) in + let on_stack = Hashtbl.create (List.length names) in + let stack = Stack.create () in + let components = ref [] in + let rec visit name = + let index_value = !next_index in + incr next_index; + Hashtbl.add indices name index_value; + Hashtbl.add lowlinks name index_value; + Stack.push name stack; + Hashtbl.add on_stack name (); + dependencies index name + |> List.iter (fun dependency -> + match Hashtbl.find_opt indices dependency with + | None -> + visit dependency; + Hashtbl.replace lowlinks name + (min + (Hashtbl.find lowlinks name) + (Hashtbl.find lowlinks dependency)) + | Some dependency_index when Hashtbl.mem on_stack dependency -> + Hashtbl.replace lowlinks name + (min (Hashtbl.find lowlinks name) dependency_index) + | Some _ -> ()); + if Hashtbl.find lowlinks name = index_value then ( + let component = ref [] in + let complete = ref false in + while not !complete do + let member = Stack.pop stack in + Hashtbl.remove on_stack member; + component := member :: !component; + complete := member = name + done; + components := !component :: !components) + in + List.iter + (fun name -> if not (Hashtbl.mem indices name) then visit name) + names; + let component_by_name = Hashtbl.create (List.length names) in + !components + |> List.iteri (fun component_id component -> + if List.length component > 1 then + List.iter + (fun name -> Hashtbl.add component_by_name name component_id) + component); + let same_cyclic_component left right = + match + ( Hashtbl.find_opt component_by_name left, + Hashtbl.find_opt component_by_name right ) + with + | Some left, Some right -> left = right + | _ -> false + in + let best = ref None in + let best_length = ref max_int in + let consider cycle = + let cycle = canonical_cycle cycle in + match !best with + | None -> + best := Some cycle; + best_length := List.length cycle + | Some current -> + let cycle_length = List.length cycle in + if + cycle_length < !best_length + || (cycle_length = !best_length && cycle < current) + then ( + best := Some cycle; + best_length := cycle_length) + in + names + |> List.filter (Hashtbl.mem component_by_name) + |> List.iter (fun start -> + let queue = Queue.create () in + let parents = Hashtbl.create 16 in + let distances = Hashtbl.create 16 in + Hashtbl.add distances start 0; + Queue.add start queue; + let found = ref false in + while (not !found) && not (Queue.is_empty queue) do + let current = Queue.take queue in + let distance = Hashtbl.find distances current in + let can_improve = distance + 2 <= !best_length in + if can_improve then + dependencies index current + |> List.iter (fun dependency -> + if not (same_cyclic_component start dependency) then () + else if dependency = start then ( + let rec path_to_start acc node_name = + if node_name = start then start :: acc + else + path_to_start (node_name :: acc) + (Hashtbl.find parents node_name) + in + consider (path_to_start [] current @ [start]); + found := true) + else if not (Hashtbl.mem distances dependency) then ( + Hashtbl.add parents dependency current; + Hashtbl.add distances dependency (distance + 1); + Queue.add dependency queue)) + done); + !best) + +let shortest_cycle nodes ~name ~deps = + create_index nodes ~name ~deps + ~validation:Replace_duplicates_and_ignore_unknown + |> shortest_cycle_in_index diff --git a/rewatch-ocaml/graph.mli b/rewatch-ocaml/graph.mli new file mode 100644 index 00000000000..b08bb7fee66 --- /dev/null +++ b/rewatch-ocaml/graph.mli @@ -0,0 +1,34 @@ +(** Graph indexing is shared by cycle analysis and subprocess scheduling, but + their input contracts differ. The validation policy makes replacement or + rejection of duplicate and unknown nodes an explicit caller decision. *) +type validation = + | Replace_duplicates_and_ignore_unknown + | Reject_invalid of { + duplicate_node: string -> exn; + unknown_dependency: node:string -> dependency:string -> exn; + } + +type 'a index + +val create_index : + 'a list -> + name:('a -> string) -> + deps:('a -> string list) -> + validation:validation -> + 'a index + +val node_count : 'a index -> int +val find_node : 'a index -> string -> 'a +val dependencies : 'a index -> string -> string list +val dependents : 'a index -> string -> string list +val dependency_count : 'a index -> string -> int +val shortest_cycle_in_index : 'a index -> string list option + +val cycle_blocked_nodes : + 'a list -> name:('a -> string) -> deps:('a -> string list) -> 'a list + +val shortest_cycle : + 'a list -> + name:('a -> string) -> + deps:('a -> string list) -> + string list option diff --git a/rewatch-ocaml/module_graph.ml b/rewatch-ocaml/module_graph.ml new file mode 100644 index 00000000000..8c55185428c --- /dev/null +++ b/rewatch-ocaml/module_graph.ml @@ -0,0 +1,379 @@ +type cycle_info = { + cycle: string list; + blocked: string list; + nodes_by_key: (string, cycle_node) Hashtbl.t; +} + +and cycle_node = { + key: string; + package_root: string; + source_path: string option; + display_name: string; +} + +type module_node = { + key: string; + package_name: string; + package_root: string; + source_path: string; + namespace: Config.namespace; + visible_packages: (string, unit) Hashtbl.t; + mutable raw_dependencies: string list; +} + +type namespace_map = { + key: string; + compiler_name: string; + namespace: string; + package_name: string; + package_root: string; + members: string list; +} + +let namespace_map_key package_root = "\000namespace:" ^ package_root + +let dependency_head dependency = + match String.split_on_char '.' dependency with + | head :: _ -> head + | [] -> dependency + +let compiler_namespace (config : Config.t) = + Config.namespace_compiler_name config.namespace + +let validate_visible_namespaces ~(root_config : Config.t) + (package_plans : Package_plan.t list) = + let by_root = Hashtbl.create (List.length package_plans) in + List.iter + (fun (package : Package_plan.t) -> + Hashtbl.replace by_root package.root package) + package_plans; + package_plans + |> List.sort (fun first second -> + String.compare first.Package_plan.root second.root) + |> List.iter (fun consumer -> + let visible = + consumer + :: (consumer.Package_plan.dependencies + |> List.filter_map (fun dependency -> + Hashtbl.find_opt by_root dependency.Package_plan.directory)) + in + let namespaces = Hashtbl.create (List.length visible) in + visible + |> List.sort (fun first second -> + String.compare first.Package_plan.root second.root) + |> List.iter (fun package -> + compiler_namespace package.Package_plan.compile_config + |> Option.iter (fun namespace -> + match Hashtbl.find_opt namespaces namespace with + | None -> Hashtbl.add namespaces namespace package + | Some previous when previous.Package_plan.root = package.root -> + () + | Some previous -> + let display package = + Printf.sprintf "%s (%s)" package.Package_plan.config.name + (Project_context.display_path ~root:root_config.root + package.root) + in + raise + (Project_context.Error + (Printf.sprintf + "Could not initialize build: Namespace %s is provided \ + by both %s and %s while building %s. Give the \ + packages distinct namespaces." + namespace (display previous) (display package) + (display consumer)))))) + +let resolve_dependency ~find_module ~find_namespace_maps (node : module_node) + dependency = + let raw_name = dependency_head dependency in + let local_name = + match + (Config.namespace_name node.namespace, String.split_on_char '.' dependency) + with + | Some namespace, first :: second :: _ when first = namespace -> second + | _ -> raw_name + in + let local_key = Config.namespaced_module_name node.namespace local_name in + let is_visible (dependency_node : module_node) = + Hashtbl.mem node.visible_packages dependency_node.package_name + in + match find_module local_key with + | Some dependency_node when is_visible dependency_node -> [local_key] + | _ when Config.namespace_name node.namespace = Some raw_name -> [] + | _ -> ( + match find_module raw_name with + | Some dependency_node when is_visible dependency_node -> [raw_name] + | _ -> ( + let explicit_namespaced_module = + match String.split_on_char '.' dependency with + | namespace :: module_name :: _ -> + [module_name ^ "-" ^ namespace; module_name ^ "-@" ^ namespace] + |> List.find_opt (fun key -> + match find_module key with + | Some dependency_node + when Config.namespace_name dependency_node.namespace + = Some namespace + && is_visible dependency_node -> + true + | Some _ | None -> false) + | _ -> None + in + match explicit_namespaced_module with + | Some key -> [key] + | None -> + find_namespace_maps raw_name + |> Option.value ~default:[] + |> List.filter_map (fun (namespace_map : namespace_map) -> + if Hashtbl.mem node.visible_packages namespace_map.package_name then + Some namespace_map.key + else None))) + +let resolved_dependencies ~find_module ~find_namespace_maps (node : module_node) + = + let parsed = + node.raw_dependencies + |> List.concat_map + (resolve_dependency ~find_module ~find_namespace_maps node) + in + let implicit_namespace_entry = + match node.namespace with + | Config.Namespace_with_entry {name = namespace; entry} + when Source.module_name node.source_path = entry -> + find_namespace_maps namespace + |> Option.value ~default:[] + |> List.find_map (fun (namespace_map : namespace_map) -> + if namespace_map.package_root = node.package_root then + Some namespace_map.key + else None) + |> Option.to_list + | Config.Namespace_with_entry _ | Config.Namespace _ | Config.No_namespace + -> + [] + in + parsed @ implicit_namespace_entry + |> List.filter (fun dependency -> dependency <> node.key) + |> List.sort_uniq String.compare + +type initialized = { + nodes: module_node list; + namespace_maps: namespace_map list; + build_state: Build_state.t; + use_existing_ast_paths: string list; +} + +let initialize ~(root_config : Config.t) ~package_plans ~compile_assets + ~failed_parse_paths = + let nodes = ref [] in + let use_existing_ast_paths = ref [] in + List.iter + (fun (package : Package_plan.t) -> + let visible_packages = + Hashtbl.create (List.length package.dependencies + 1) + in + Hashtbl.replace visible_packages package.name (); + List.iter + (fun (dependency : Package_plan.dependency) -> + Hashtbl.replace visible_packages dependency.declaration.name ()) + package.dependencies; + List.iter + (fun module_ -> + let dependencies path = + if + Hashtbl.mem failed_parse_paths (Filename.concat package.root path) + then [] + else + Compiler_process.ast_dependencies ~build_dir:package.build_dir + (Source.ast_path path) + in + let raw_dependencies = + List.sort_uniq String.compare + (dependencies module_.Source.implementation + @ + match module_.Source.interface with + | None -> [] + | Some path -> dependencies path) + in + let compiler_base = + Source.compiler_basename package.compile_config module_.Source.name + in + if Option.is_none (Compile_assets.cmt compile_assets compiler_base) + then + use_existing_ast_paths := + Filename.concat package.root module_.Source.implementation + :: !use_existing_ast_paths; + nodes := + { + key = compiler_base; + package_name = package.name; + package_root = package.root; + source_path = module_.Source.implementation; + namespace = package.compile_config.namespace; + visible_packages; + raw_dependencies; + } + :: !nodes) + package.modules) + package_plans; + let nodes = + List.sort + (fun (first : module_node) second -> String.compare first.key second.key) + !nodes + in + let by_key : (string, module_node) Hashtbl.t = + Hashtbl.create (List.length nodes) + in + List.iter + (fun (node : module_node) -> + match Hashtbl.find_opt by_key node.key with + | None -> Hashtbl.add by_key node.key node + | Some previous -> + raise + (Source.duplicate_error ~display_root:root_config.root "" node.key + (Filename.concat previous.package_root previous.source_path) + (Filename.concat node.package_root node.source_path))) + nodes; + let namespace_maps = + package_plans + |> List.filter_map (fun (package : Package_plan.t) -> + let namespace = package.Package_plan.compile_config.namespace in + let namespace_details = + match namespace with + | Config.No_namespace -> None + | Config.Namespace name -> Some (name, name, None) + | Config.Namespace_with_entry {name; entry} -> + Some ("@" ^ name, name, Some entry) + in + namespace_details + |> Option.map (fun (compiler_name, name, namespace_entry) -> + let members = + Source.namespace_members ~entry:namespace_entry package.modules + |> List.map (fun module_ -> + Source.compiler_basename package.compile_config + module_.Source.name) + |> List.sort_uniq String.compare + in + { + key = namespace_map_key package.root; + compiler_name; + namespace = name; + package_name = package.name; + package_root = package.root; + members; + })) + in + let namespace_maps_by_name = Hashtbl.create (List.length namespace_maps) in + List.iter + (fun (namespace_map : namespace_map) -> + let existing = + Hashtbl.find_opt namespace_maps_by_name namespace_map.namespace + |> Option.value ~default:[] + in + Hashtbl.replace namespace_maps_by_name namespace_map.namespace + (namespace_map :: existing)) + namespace_maps; + let source_graph_nodes = + List.map + (fun (node : module_node) -> + ( node, + resolved_dependencies ~find_module:(Hashtbl.find_opt by_key) + ~find_namespace_maps:(Hashtbl.find_opt namespace_maps_by_name) + node )) + nodes + in + let build_state = + Build_state.create + (List.length source_graph_nodes + List.length namespace_maps) + in + let modified = Option.map (fun entry -> entry.Compile_assets.modified) in + List.iter + (fun ((node : module_node), _) -> + Build_state.add build_state ~key:node.key ~kind:Build_state.Source_module + ~last_compiled_cmi: + (Compile_assets.cmi compile_assets node.key |> modified) + ~last_compiled_cmt: + (Compile_assets.cmt compile_assets node.key |> modified)) + source_graph_nodes; + List.iter + (fun (namespace_map : namespace_map) -> + Build_state.add build_state ~key:namespace_map.key + ~kind:Build_state.Namespace_map + ~last_compiled_cmi: + (Compile_assets.cmi compile_assets namespace_map.compiler_name + |> modified) + ~last_compiled_cmt: + (Compile_assets.cmt compile_assets namespace_map.compiler_name + |> modified)) + namespace_maps; + List.iter + (fun ((node : module_node), dependencies) -> + Build_state.set_dependencies build_state ~key:node.key dependencies) + source_graph_nodes; + List.iter + (fun (namespace_map : namespace_map) -> + Build_state.set_dependencies build_state ~key:namespace_map.key + namespace_map.members) + namespace_maps; + { + nodes; + namespace_maps; + build_state; + use_existing_ast_paths = !use_existing_ast_paths; + } + +let find_cycle modules namespace_maps build_state = + let nodes_by_key = + Hashtbl.create (List.length modules + List.length namespace_maps) + in + List.iter + (fun (node : module_node) -> + let key = node.key in + let module_name = Source.module_name node.source_path in + let display_name = + match node.namespace with + | Config.Namespace_with_entry {name = namespace; entry} + when entry <> module_name -> + namespace ^ "." ^ module_name + | Config.Namespace namespace -> namespace ^ "." ^ module_name + | Config.Namespace_with_entry _ | Config.No_namespace -> module_name + in + Hashtbl.add nodes_by_key key + { + key; + package_root = node.package_root; + source_path = Some node.source_path; + display_name; + }) + modules; + List.iter + (fun (namespace_map : namespace_map) -> + let key = namespace_map.key in + Hashtbl.add nodes_by_key key + { + key; + package_root = namespace_map.package_root; + source_path = None; + display_name = namespace_map.compiler_name; + }) + namespace_maps; + let graph_nodes = + Hashtbl.to_seq_values nodes_by_key + |> List.of_seq + |> List.sort (fun (first : cycle_node) second -> + String.compare first.key second.key) + |> List.map (fun (node : cycle_node) -> + (node, (Build_state.find_exn build_state node.key).dependencies)) + in + let name ((node : cycle_node), _) = node.key in + let blocked_nodes = Graph.cycle_blocked_nodes graph_nodes ~name ~deps:snd in + match blocked_nodes with + | [] -> None + | _ -> + let cycle = + match Graph.shortest_cycle blocked_nodes ~name ~deps:snd with + | Some cycle -> cycle + | None -> + raise + (Project_context.Error + "cycle-blocked dependency graph contains no detectable cycle") + in + Some {cycle; blocked = List.map name blocked_nodes; nodes_by_key} diff --git a/rewatch-ocaml/module_graph.mli b/rewatch-ocaml/module_graph.mli new file mode 100644 index 00000000000..e1e904029c0 --- /dev/null +++ b/rewatch-ocaml/module_graph.mli @@ -0,0 +1,64 @@ +(** Namespace maps are graph nodes because adding or removing a namespace + member can invalidate consumers even when no ordinary source dependency + name changes. The graph therefore contains both source modules and these + synthetic nodes. *) + +type cycle_info = { + cycle: string list; + blocked: string list; + nodes_by_key: (string, cycle_node) Hashtbl.t; +} + +and cycle_node = { + key: string; + package_root: string; + source_path: string option; + display_name: string; +} + +type module_node = { + key: string; + package_name: string; + package_root: string; + source_path: string; + namespace: Config.namespace; + visible_packages: (string, unit) Hashtbl.t; + mutable raw_dependencies: string list; +} + +type namespace_map = { + key: string; + compiler_name: string; + namespace: string; + package_name: string; + package_root: string; + members: string list; +} + +val namespace_map_key : string -> string + +val validate_visible_namespaces : + root_config:Config.t -> Package_plan.t list -> unit + +val resolved_dependencies : + find_module:(string -> module_node option) -> + find_namespace_maps:(string -> namespace_map list option) -> + module_node -> + string list + +type initialized = { + nodes: module_node list; + namespace_maps: namespace_map list; + build_state: Build_state.t; + use_existing_ast_paths: string list; +} + +val initialize : + root_config:Config.t -> + package_plans:Package_plan.t list -> + compile_assets:Compile_assets.t -> + failed_parse_paths:(string, unit) Hashtbl.t -> + initialized + +val find_cycle : + module_node list -> namespace_map list -> Build_state.t -> cycle_info option diff --git a/rewatch-ocaml/native_watcher.ml b/rewatch-ocaml/native_watcher.ml new file mode 100644 index 00000000000..ee8f670b75d --- /dev/null +++ b/rewatch-ocaml/native_watcher.ml @@ -0,0 +1,304 @@ +type change_kind = Content | Structural +type change = {path: string option; kind: change_kind} + +type wait_result = Changed of change list | Stopped | Failed of string + +type watch_path = {directory: string; recursive: bool} + +type watched_directory = { + directory: string; + identity: string; + handle: Luv.FS_event.t; +} + +type t = { + loop: Luv.Loop.t; + timer: Luv.Timer.t; + mutable handles: watched_directory list; + mutable changes: change list; + mutable stopped: bool; + mutable error: string option; +} + +let error_message error = + Printf.sprintf "%s: %s" (Luv.Error.err_name error) (Luv.Error.strerror error) + +let is_compiler_artifact_directory path = + let name = Filename.basename path in + (name = "bs" || name = "ocaml") + && Filename.basename (Filename.dirname path) = "lib" + +let directories_under paths = + let visited = Hashtbl.create 64 in + let rec walk acc directory = + match Platform.canonicalize_path directory with + | canonical -> ( + if Hashtbl.mem visited canonical then acc + else + let entries = + try Some (File_util.directory_entries canonical) + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> None + in + match entries with + | None -> acc + | Some entries -> + Hashtbl.add visited canonical (); + entries + |> List.fold_left + (fun acc name -> + let path = Filename.concat canonical name in + if is_compiler_artifact_directory path then acc + else + match Unix.stat path with + | stat -> + if stat.Unix.st_kind = Unix.S_DIR then walk acc path + else acc + | exception + Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> + acc) + (canonical :: acc)) + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> acc + in + paths + |> List.fold_left + (fun directories path -> + if path.recursive then walk directories path.directory + else + match Platform.canonicalize_path path.directory with + | canonical -> canonical :: directories + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> + directories) + [] + |> List.sort_uniq String.compare + +let close_fs_handles loop handles = + let pending = ref 0 in + List.iter + (fun handle -> + ignore (Luv.FS_event.stop handle); + if not (Luv.Handle.is_closing handle) then ( + incr pending; + Luv.Handle.close handle (fun () -> decr pending))) + handles; + while !pending > 0 do + ignore (Luv.Loop.run ~loop ~mode:`NOWAIT ()) + done + +let close_timer loop timer = + if not (Luv.Handle.is_closing timer) then ( + let closed = ref false in + Luv.Handle.close timer (fun () -> closed := true); + while not !closed do + ignore (Luv.Loop.run ~loop ~mode:`NOWAIT ()) + done) + +let remove_handles watcher = + close_fs_handles watcher.loop + (List.map (fun watched -> watched.handle) watcher.handles); + watcher.handles <- [] + +let close watcher = + remove_handles watcher; + ignore (Luv.Timer.stop watcher.timer); + close_timer watcher.loop watcher.timer; + ignore (Luv.Loop.close watcher.loop) + +let directory_identity directory = + try + let metadata = Unix.stat directory in + Ok (Platform.directory_identity ~path:directory metadata) + with (Sys_error _ | Unix.Unix_error _) as error -> + Error + (Printf.sprintf "could not identify watched directory %s: %s" directory + (Printexc.to_string error)) + +let identify_directories ~directory_identity directories = + let rec identify identified = function + | [] -> Ok (List.rev identified) + | directory :: rest -> ( + match directory_identity directory with + | Ok identity -> identify ((directory, identity) :: identified) rest + | Error _ as error -> error) + in + identify [] directories + +let install_handles watcher identified_directories = + let existing = Hashtbl.create (List.length watcher.handles) in + List.iter + (fun watched -> Hashtbl.add existing watched.directory ()) + watcher.handles; + let install (directory, identity) = + if not (Hashtbl.mem existing directory) then + match Luv.FS_event.init ~loop:watcher.loop () with + | Error error -> watcher.error <- Some (error_message error) + | Ok handle -> + watcher.handles <- {directory; identity; handle} :: watcher.handles; + Luv.FS_event.start handle directory (function + | Ok (filename, events) -> + let path = + match filename with + | None | Some "" -> None + | Some filename -> + Some + (if Filename.is_relative filename then + Filename.concat directory filename + else filename) + in + let kind = + if List.mem `CHANGE events && not (List.mem `RENAME events) then + Content + else Structural + in + watcher.changes <- {path; kind} :: watcher.changes; + Luv.Loop.stop watcher.loop + | Error error -> + watcher.error <- Some (error_message error); + Luv.Loop.stop watcher.loop) + in + List.iter install identified_directories; + match watcher.error with + | None -> Ok () + | Some message -> Error message + +let create_with_directory_identity ~directory_identity ~paths = + match Luv.Loop.init () with + | Error error -> Error (error_message error) + | Ok loop -> ( + match Luv.Timer.init ~loop () with + | Error error -> + ignore (Luv.Loop.close loop); + Error (error_message error) + | Ok timer -> ( + let watcher = + {loop; timer; handles = []; changes = []; stopped = false; error = None} + in + let fail message = + try + close watcher; + Error message + with cleanup_error -> + Error + (Printf.sprintf "%s (native watcher cleanup failed: %s)" message + (Printexc.to_string cleanup_error)) + in + match + try + match + identify_directories ~directory_identity (directories_under paths) + with + | Error _ as error -> error + | Ok directories -> install_handles watcher directories + with error -> Error (Printexc.to_string error) + with + | Ok () -> Ok watcher + | Error message -> fail message)) + +let create = create_with_directory_identity ~directory_identity + +let wait watcher ~keep_running = + watcher.stopped <- not (keep_running ()); + (* Events can arrive while refresh closes handles and pumps the libuv loop. + Consume the event that wakes this call, but never erase one already queued + by reconciliation before the next wait begins. A failed handle or an + external stop must take precedence because another reconciliation could + otherwise erase the condition that requires native watch to end. *) + let ready_result () = + match watcher.error with + | Some message -> Some (Failed message) + | None when watcher.stopped -> Some Stopped + | None when watcher.changes <> [] -> + let changes = List.rev watcher.changes in + watcher.changes <- []; + Some (Changed changes) + | None -> None + in + match ready_result () with + | Some result -> result + | None -> ( + let check_running () = + try + if not (keep_running ()) then ( + watcher.stopped <- true; + Luv.Loop.stop watcher.loop) + with error -> + watcher.error <- Some (Printexc.to_string error); + Luv.Loop.stop watcher.loop + in + (match Luv.Timer.start ~repeat:100 watcher.timer 100 check_running with + | Ok () -> () + | Error error -> watcher.error <- Some (error_message error)); + while + watcher.changes = [] && (not watcher.stopped) + && Option.is_none watcher.error + do + ignore (Luv.Loop.run ~loop:watcher.loop ~mode:`ONCE ()) + done; + ignore (Luv.Timer.stop watcher.timer); + match ready_result () with + | Some result -> result + | None -> Failed "native watcher loop stopped without a wakeup condition") + +let drain watcher = + (* The first callback stops the loop so the build can start promptly. Pump + already-ready callbacks after the debounce window to keep one editor save + together without waiting for another build cycle. *) + let rec pump remaining = + if remaining > 0 then ( + let changes = watcher.changes in + ignore (Luv.Loop.run ~loop:watcher.loop ~mode:`NOWAIT ()); + if watcher.changes != changes then pump (remaining - 1)) + in + pump 1024; + let changes = List.rev watcher.changes in + watcher.changes <- []; + changes + +let watches_directory watcher path = + List.exists (fun watched -> watched.directory = path) watcher.handles + +let refresh_with_directory_identity ~directory_identity watcher ~paths = + (* Only failures from work completed before this refresh are stale. Clear + them before pumping close callbacks so a newly reported handle failure is + preserved and makes the caller fall back to polling. *) + watcher.error <- None; + match + try Ok (directories_under paths) + with (Unix.Unix_error _ | Sys_error _) as error -> + Error (Printexc.to_string error) + with + | Error _ as error -> error + | Ok directories -> ( + match identify_directories ~directory_identity directories with + | Error _ as error -> error + | Ok identified_directories -> + let desired = Hashtbl.create (List.length directories) in + List.iter + (fun (directory, identity) -> Hashtbl.add desired directory identity) + identified_directories; + let kept, removed = + List.partition + (fun watched -> + Hashtbl.find_opt desired watched.directory = Some watched.identity) + watcher.handles + in + close_fs_handles watcher.loop + (List.map (fun watched -> watched.handle) removed); + watcher.handles <- kept; + install_handles watcher identified_directories) + +let refresh = refresh_with_directory_identity ~directory_identity + +module For_test = struct + let create_with_directory_identity = create_with_directory_identity + let refresh_with_directory_identity = refresh_with_directory_identity + let handle_count watcher = List.length watcher.handles + let directory_identity watcher directory = + watcher.handles + |> List.find_opt (fun watched -> watched.directory = directory) + |> Option.map (fun watched -> watched.identity) + + let queue_change watcher = + watcher.changes <- {path = None; kind = Structural} :: watcher.changes + + let queue_error watcher message = watcher.error <- Some message +end diff --git a/rewatch-ocaml/native_watcher.mli b/rewatch-ocaml/native_watcher.mli new file mode 100644 index 00000000000..089a30fcb02 --- /dev/null +++ b/rewatch-ocaml/native_watcher.mli @@ -0,0 +1,37 @@ +type t +(** A native watcher is only a low-latency notification source. Callers must + reconcile notifications against {!Watch_snapshot}; operating systems may + merge, duplicate, reorder, or omit the pathname attached to an event. *) + +type change_kind = Content | Structural +type change = {path: string option; kind: change_kind} + +type watch_path = {directory: string; recursive: bool} + +type wait_result = Changed of change list | Stopped | Failed of string + +val create : paths:watch_path list -> (t, string) result +val is_compiler_artifact_directory : string -> bool +val wait : t -> keep_running:(unit -> bool) -> wait_result +val drain : t -> change list +val watches_directory : t -> string -> bool +val refresh : t -> paths:watch_path list -> (unit, string) result +val close : t -> unit + +module For_test : sig + val create_with_directory_identity : + directory_identity:(string -> (string, string) result) -> + paths:watch_path list -> + (t, string) result + + val refresh_with_directory_identity : + directory_identity:(string -> (string, string) result) -> + t -> + paths:watch_path list -> + (unit, string) result + + val handle_count : t -> int + val directory_identity : t -> string -> string option + val queue_change : t -> unit + val queue_error : t -> string -> unit +end diff --git a/rewatch-ocaml/output.ml b/rewatch-ocaml/output.ml new file mode 100644 index 00000000000..2f6e5c923f5 --- /dev/null +++ b/rewatch-ocaml/output.ml @@ -0,0 +1,169 @@ +let line_clear = "\027[2K\r" + +let log ~minimum ~verbosity ~label message = + if verbosity >= minimum then Printf.printf "%s:\n%s\n%!" label message + +let debug ~verbosity message = log ~minimum:1 ~verbosity ~label:"DEBUG" message +let trace ~verbosity message = log ~minimum:2 ~verbosity ~label:"TRACE" message +let trace_enabled verbosity = verbosity >= 2 + +let format_step ~color step = + if color then Printf.sprintf "\027[1m\027[2m[%s]\027[0m" step + else Printf.sprintf "[%s]" step + +module Progress = struct + type phase = { + step: string; + symbol: string; + label: string; + total: int; + mutable position: int; + } + + type t = { + enabled: bool; + color: bool; + mutable phase: phase option; + mutable frame: int; + mutable next_draw: float; + } + + let frames = [|"⠁"; "⠂"; "⠄"; "⡀"; "⢀"; "⠠"; "⠐"; "⠈"|] + let create ~enabled ~color = + {enabled; color; phase = None; frame = 0; next_draw = 0.} + + let draw ?(force = false) progress = + if progress.enabled then + match progress.phase with + | None -> () + | Some phase -> + let now = Unix.gettimeofday () in + if force || now >= progress.next_draw then ( + let spinner = frames.(progress.frame mod Array.length frames) in + progress.frame <- progress.frame + 1; + progress.next_draw <- now +. 0.08; + Printf.printf "%s%s %s%s... %s %d/%d %!" line_clear + (format_step ~color:progress.color phase.step) + phase.symbol phase.label spinner phase.position phase.total) + + let start progress ~step ~symbol ~label ~total = + progress.phase <- Some {step; symbol; label; total; position = 0}; + progress.frame <- 0; + progress.next_draw <- 0.; + draw ~force:true progress + + let advance progress = + Option.iter + (fun phase -> + if phase.position < phase.total then + phase.position <- phase.position + 1) + progress.phase; + draw progress + + let tick progress = draw progress + let finish progress = progress.phase <- None + + let debug progress ~verbosity message = + if verbosity >= 1 then ( + let redraw = progress.enabled && Option.is_some progress.phase in + if redraw then Printf.printf "%s%!" line_clear; + log ~minimum:1 ~verbosity ~label:"DEBUG" message; + if redraw then draw ~force:true progress) + + let start_grouped progress ~step ~symbol ~label groups = + let remaining = Hashtbl.create (List.length groups) in + List.iter + (fun group -> + let count = + Hashtbl.find_opt remaining group |> Option.value ~default:0 + in + Hashtbl.replace remaining group (count + 1)) + groups; + start progress ~step ~symbol ~label ~total:(Hashtbl.length remaining); + let groups = Array.of_list groups in + fun index -> + let group = groups.(index) in + let count = Hashtbl.find remaining group - 1 in + Hashtbl.replace remaining group count; + if count = 0 then advance progress +end + +let yellow text = + if String.starts_with ~prefix:"\n" text then + "\n\027[33m" ^ String.sub text 1 (String.length text - 1) ^ "\027[0m" + else "\027[33m" ^ text ^ "\027[0m" + +let colors_enabled_with ~getenv ~win32 ~interactive = + let nonzero name default = + match getenv name with + | None -> default + | Some value -> value <> "0" + in + let terminal_supports_color = + interactive + && + if win32 then true + else + Option.is_none (getenv "NO_COLOR") + && + match getenv "TERM" with + | Some term -> term <> "dumb" + | None -> false + in + (terminal_supports_color && nonzero "CLICOLOR" true) + || nonzero "CLICOLOR_FORCE" false + +let colors_enabled ~interactive = + colors_enabled_with ~getenv:Sys.getenv_opt + ~win32:Platform.terminal_supports_color_without_term ~interactive + +let cleanup_message ~color ~step ~cleaned ~total ~seconds = + Printf.sprintf "%s%s %sCleaned %d/%d in %.2fs" line_clear + (format_step ~color step) Platform.clean_symbol cleaned total seconds + +let compiler_cleanup_message ~color ~step = + Printf.sprintf "%s%s %sCleaned previous build due to compiler update" + line_clear (format_step ~color step) Platform.clean_symbol + +let cleaning_command_message ~color ~step target = + Printf.sprintf "%s%s %sCleaning %s..." line_clear (format_step ~color step) + Platform.clean_symbol target + +let cleaned_command_message ~color ~step ~target ~seconds = + Printf.sprintf "%s%s %sCleaned %s in %.2fs" line_clear + (format_step ~color step) Platform.clean_symbol target seconds + +let parsing_message ~color ~step ~count ~seconds = + Printf.sprintf "%s%s %sParsed %d source files in %.2fs" line_clear + (format_step ~color step) Platform.parse_symbol count seconds + +let parsing_failed_message ~color ~step ~seconds = + Printf.sprintf "%s%s %sError parsing source files in %.2fs" line_clear + (format_step ~color step) Platform.error_symbol seconds + +let compiling_message ~color ~step ~count ~seconds = + Printf.sprintf "%s%s %sCompiled %d modules in %.2fs" line_clear + (format_step ~color step) Platform.build_symbol count seconds + +let compilation_failed_message ~color ~step ~count ~seconds = + Printf.sprintf "%s%s %sCompiled %d modules in %.2fs" line_clear + (format_step ~color step) Platform.error_symbol count seconds + +type compilation_label = Standard | Initial | Incremental + +let finished_compilation_message ~label ~warnings ~seconds = + let status = + if warnings then Platform.warning_symbol else Platform.success_symbol + in + let kind = + match label with + | Standard -> "" + | Initial -> "initial " + | Incremental -> "incremental " + in + let warning_suffix = if warnings then " with warnings" else "" in + Printf.sprintf "%s%sFinished %scompilation%s in %.2fs" line_clear status kind + warning_suffix seconds + +let should_clear_screen ~clear_screen ~show_progress ~interactive = + clear_screen && show_progress && interactive diff --git a/rewatch-ocaml/output.mli b/rewatch-ocaml/output.mli new file mode 100644 index 00000000000..78b22430bd3 --- /dev/null +++ b/rewatch-ocaml/output.mli @@ -0,0 +1,61 @@ +val debug : verbosity:int -> string -> unit +val trace : verbosity:int -> string -> unit +val trace_enabled : int -> bool + +module Progress : sig + type t + + val create : enabled:bool -> color:bool -> t + + val start : + t -> step:string -> symbol:string -> label:string -> total:int -> unit + + val advance : t -> unit + val tick : t -> unit + val finish : t -> unit + val debug : t -> verbosity:int -> string -> unit + + val start_grouped : + t -> step:string -> symbol:string -> label:string -> 'a list -> int -> unit +end + +val yellow : string -> string + +val colors_enabled_with : + getenv:(string -> string option) -> win32:bool -> interactive:bool -> bool + +val colors_enabled : interactive:bool -> bool + +val cleanup_message : + color:bool -> + step:string -> + cleaned:int -> + total:int -> + seconds:float -> + string + +val compiler_cleanup_message : color:bool -> step:string -> string +val cleaning_command_message : color:bool -> step:string -> string -> string + +val cleaned_command_message : + color:bool -> step:string -> target:string -> seconds:float -> string + +val parsing_message : + color:bool -> step:string -> count:int -> seconds:float -> string + +val parsing_failed_message : + color:bool -> step:string -> seconds:float -> string + +val compiling_message : + color:bool -> step:string -> count:int -> seconds:float -> string + +val compilation_failed_message : + color:bool -> step:string -> count:int -> seconds:float -> string + +type compilation_label = Standard | Initial | Incremental + +val finished_compilation_message : + label:compilation_label -> warnings:bool -> seconds:float -> string + +val should_clear_screen : + clear_screen:bool -> show_progress:bool -> interactive:bool -> bool diff --git a/rewatch-ocaml/package_build.ml b/rewatch-ocaml/package_build.ml new file mode 100644 index 00000000000..3dc61d22b15 --- /dev/null +++ b/rewatch-ocaml/package_build.ml @@ -0,0 +1,53 @@ +exception Error = Project_context.Error + +let prepare_removed_modules ~(package : Package_plan.t) + (attempt : Build_attempt.t) = + let cleanup = + match Build_attempt.find_cleanup_result attempt package.root with + | Some result -> result + | None -> + raise (Error ("Package cleanup was not prepared for " ^ package.root)) + in + let removed = Hashtbl.create (List.length cleanup.removed_modules) in + List.iter + (fun module_name -> + Hashtbl.replace removed module_name (); + Hashtbl.replace attempt.removed_modules module_name ()) + cleanup.removed_modules; + removed + +let rec prepare_tree ~seen ~(package : Package_plan.t) ~prepared ~watch + ~(attempt : Build_attempt.t) = + let root = package.root in + Hashtbl.replace seen root (); + attempt.diagnostics <- + List.rev_append + (Package_diagnostics.for_package ~is_local:package.is_local package.config) + attempt.diagnostics; + List.iter + (fun (dependency : Package_plan.dependency) -> + let root = dependency.directory in + if not (Hashtbl.mem seen root) then + match Build_session.find_package_plan attempt.session root with + | None -> () + | Some dependency_package -> + prepare_tree ~seen ~package:dependency_package ~prepared ~watch + ~attempt) + package.dependencies; + File_util.ensure_dir package.build_dir; + File_util.ensure_dir package.ocaml_dir; + Compiler_log.initialize root; + Build_attempt.mark_log_initialized attempt root; + let prepared_package = + match Hashtbl.find_opt prepared.Build_session.packages root with + | Some package -> package + | None -> + raise (Error ("Package build was not prepared for " ^ package.root)) + in + let removed_module_names = prepare_removed_modules ~package attempt in + let parse_dirty_modules = + Package_parse.run ~package ~prepared ~prepared_package ~attempt + ~removed_module_names + in + Package_compilation.prepare ~package ~prepared ~prepared_package ~attempt + ~watch ~removed_module_names ~parse_dirty_modules diff --git a/rewatch-ocaml/package_build.mli b/rewatch-ocaml/package_build.mli new file mode 100644 index 00000000000..bcdbc8f8635 --- /dev/null +++ b/rewatch-ocaml/package_build.mli @@ -0,0 +1,11 @@ +val prepare_tree : + seen:(string, unit) Hashtbl.t -> + package:Package_plan.t -> + prepared:Build_session.prepared -> + watch:bool -> + attempt:Build_attempt.t -> + unit +(** Package preparation remains recursive because dependencies must register + their compiler work before a consumer can be scheduled. [seen] prevents a + package graph cycle or duplicate dependency edge from preparing a package + twice within the attempt. *) diff --git a/rewatch-ocaml/package_compilation.ml b/rewatch-ocaml/package_compilation.ml new file mode 100644 index 00000000000..c48cbf6c23a --- /dev/null +++ b/rewatch-ocaml/package_compilation.ml @@ -0,0 +1,205 @@ +let prepare ~(package : Package_plan.t) ~(prepared : Build_session.prepared) + ~(prepared_package : Package_plan.compilation) ~(attempt : Build_attempt.t) + ~watch ~removed_module_names ~parse_dirty_modules = + let root = package.root in + let is_local = package.is_local in + let config = package.compile_config in + let build_state = prepared.build_state in + let compile_assets = prepared.compile_assets in + let build_dir = package.build_dir in + let ocaml_dir = package.ocaml_dir in + let modules = package.modules in + let cleanup = + match Build_attempt.find_cleanup_result attempt root with + | Some result -> result + | None -> + raise + (Project_context.Error ("Package cleanup was not prepared for " ^ root)) + in + let module_is_dirty module_ (state : Build_state.module_) = + let global_key = Source.compiler_basename config module_.Source.name in + let module_name = Source.module_name module_.Source.implementation in + let source_artifact_is_pending path = + let source = Filename.concat root path in + match + (Compile_assets.ast compile_assets source, state.last_compiled_cmt) + with + | Some ast, Some cmt_time -> ast.modified >= cmt_time + | Some _, None -> true + | None, _ -> false + in + let outputs_exist = + List.for_all + (fun spec -> + Hashtbl.mem cleanup.present_public_outputs + (Build_artifacts.generated_js_path config + module_.Source.implementation spec)) + config.package_specs + in + let raw_dependencies = + match Build_session.find_global_module attempt.session global_key with + | Some node -> node.raw_dependencies + | None -> + raise + (Project_context.Error + ("Build module was not prepared for " ^ global_key)) + in + let dependency_is_newer dependency = + let dependency_state = Build_state.find_exn build_state dependency in + Build_state.dependency_tree_compiled_after + ~namespace_freshness:attempt.namespace_freshness build_state state + dependency_state + in + Hashtbl.mem parse_dirty_modules module_.Source.name + || Hashtbl.mem removed_module_names module_name + || List.exists source_artifact_is_pending + (module_.Source.implementation + :: Option.to_list module_.Source.interface) + || (not (Build_state.has_complete_compile_assets state)) + || (not outputs_exist) + || List.exists (Hashtbl.mem removed_module_names) raw_dependencies + || List.exists + (fun dependency -> Hashtbl.mem attempt.removed_modules dependency) + raw_dependencies + || List.exists dependency_is_newer state.dependencies + in + if attempt.freshness_mode = Build_attempt.Initialize_freshness then + List.iter + (fun module_ -> + let key = Source.compiler_basename config module_.Source.name in + let state = Build_state.find_exn build_state key in + state.compile_dirty <- + state.compile_dirty || module_is_dirty module_ state) + modules; + if not (Build_attempt.has_parse_error attempt.parse_messages) then ( + attempt.parsed <- attempt.parsed + Hashtbl.length parse_dirty_modules; + let compile_warning_paths = Hashtbl.create 8 in + let prepare_outputs module_ = + let path = module_.Source.implementation in + List.iter + (fun spec -> + let output = Build_artifacts.generated_js_path config path spec in + File_util.ensure_dir (Filename.dirname output)) + config.package_specs + in + let compile_process module_ ~source_kind path = + Compiler_process.compile_job ~bsc:prepared.compiler_context.bsc_path + ~build_dir ~config + ~common_args: + (if module_.Source.is_dev then + prepared_package.development_common_args + else prepared_package.regular_common_args) + module_ ~source_kind path + in + let record_published_outputs ~source_kind path = + match source_kind with + | Source.Interface -> () + | Source.Implementation -> + List.iter + (fun spec -> + let output = Build_artifacts.generated_js_path config path spec in + [output; output ^ ".map"] + |> List.iter (fun path -> + if File_util.is_regular_file path then + Hashtbl.replace cleanup.present_public_outputs path ())) + config.package_specs + in + let candidates = + List.filter_map + (fun module_ -> + let key = Source.compiler_basename config module_.Source.name in + let state = Build_state.find_exn build_state key in + if Hashtbl.mem attempt.blocked_modules key then None + else + let cmi_path = + Filename.concat ocaml_dir + (Source.compiler_asset_basename config + module_.Source.implementation + ^ ".cmi") + in + let warning_paths = + module_.Source.implementation + :: Option.to_list module_.Source.interface + |> List.map (Filename.concat config.root) + in + let make () = + Compiler_scheduler.create ~key ~dependencies:state.dependencies + ~source:module_ ~state ~cmi_path + ~prepare:(fun () -> prepare_outputs module_) + ~compile:(fun ~source_kind path -> + compile_process module_ ~source_kind path) + ~publish:(fun ~source_kind path result -> + Compiler_process.publish ~build_dir ~ocaml_dir ~is_local + ~config ~source_kind path result) + ~record_published_outputs + ~post_build:(Compiler_process.post_build_tasks config) + ~package_root:config.root ~is_local + ~mark_warning:(fun _path -> + module_.Source.implementation + :: Option.to_list module_.Source.interface + |> List.iter (fun path -> + Hashtbl.replace compile_warning_paths + (Build_artifacts.published_ast_path ~ocaml_dir path) + ())) + in + Some (Compiler_scheduler.candidate ~key ~state ~warning_paths ~make)) + modules + in + Config.namespace_compiler_name config.namespace + |> Option.iter (fun compiler_name -> + let namespace_map = + Build_session.find_namespace_map attempt.session + (Module_graph.namespace_map_key root) + in + let namespace_state = + Build_state.find_exn build_state namespace_map.key + in + let package_dirty = + List.exists Compiler_scheduler.candidate_requires_compile candidates + in + if + package_dirty || namespace_state.compile_dirty + || attempt.freshness_mode = Build_attempt.Initialize_freshness + then + Compiler_process.namespace_task + ~bsc:prepared.compiler_context.bsc_path + ~runtime:prepared.compiler_context.runtime_path ~build_dir + ~ocaml_dir + ~entry:(Config.namespace_entry config.namespace) + ~package_dirty ~force:namespace_state.compile_dirty compiler_name + modules + |> Option.iter (fun namespace_task -> + namespace_state.compile_dirty <- true; + let cmi_path = + Filename.concat ocaml_dir (compiler_name ^ ".cmi") + in + let finish result = + if not (Process.succeeded result) then + ignore (namespace_task.Compiler_scheduler.publish result) + else + match + Compiler_scheduler.capture_publication (fun () -> + namespace_task.Compiler_scheduler.publish result) + with + | Compiler_scheduler.Published {cmi_change; _} -> + Build_state.record_published_cmi build_state ~compile_assets + namespace_state ~path:cmi_path cmi_change; + let cmt_path = + Filename.concat ocaml_dir (compiler_name ^ ".cmt") + in + Build_state.record_successful_compile ~compile_assets + namespace_state ~cmt_path + | Compiler_scheduler.Failed_after_cmi_publication + {error; cmi_change} -> + Build_state.record_published_cmi build_state ~compile_assets + namespace_state ~path:cmi_path cmi_change; + raise error + in + Build_attempt.add_namespace_job attempt + Build_attempt.{job = namespace_task.job; finish})); + Build_attempt.add_compile_candidates attempt candidates; + Build_attempt.register_cleanup attempt (fun () -> + if not watch then + Hashtbl.iter + (fun path () -> File_util.remove_file path) + compile_warning_paths)) diff --git a/rewatch-ocaml/package_compilation.mli b/rewatch-ocaml/package_compilation.mli new file mode 100644 index 00000000000..0e7efeebcc7 --- /dev/null +++ b/rewatch-ocaml/package_compilation.mli @@ -0,0 +1,13 @@ +val prepare : + package:Package_plan.t -> + prepared:Build_session.prepared -> + prepared_package:Package_plan.compilation -> + attempt:Build_attempt.t -> + watch:bool -> + removed_module_names:(string, unit) Hashtbl.t -> + parse_dirty_modules:(string, unit) Hashtbl.t -> + unit +(** Compilation preparation turns one stable package plan into the parse, + namespace, and compiler work owned by the current attempt. It records + pending work separately from scheduler eligibility so cycle-blocked modules + remain dirty for a later recovery build. *) diff --git a/rewatch-ocaml/package_diagnostics.ml b/rewatch-ocaml/package_diagnostics.ml new file mode 100644 index 00000000000..ffb71567d47 --- /dev/null +++ b/rewatch-ocaml/package_diagnostics.ml @@ -0,0 +1,125 @@ +let member name = function + | `Assoc fields -> List.assoc_opt name fields + | _ -> None + +let last_member name = function + | `Assoc fields -> List.assoc_opt name (List.rev fields) + | _ -> None + +let package_name package_root = + let path = Filename.concat package_root "package.json" in + try + if not (File_util.exists path) then Ok None + else + let json = Yojson.Safe.from_file path in + match last_member "name" json with + | Some (`String name) -> Ok (Some name) + | Some _ | None -> Ok None + with + | Sys_error message -> Error ("Could not read package.json: " ^ message) + | Unix.Unix_error (error, _, _) -> + Error ("Could not read package.json: " ^ Unix.error_message error) + | Yojson.Json_error message -> + Error ("Could not parse package.json: " ^ message) + +let url_value = function + | `String value -> Some value + | `Assoc fields -> ( + match List.assoc_opt "url" fields with + | Some (`String value) -> Some value + | _ -> None) + | _ -> None + +let remove_prefix prefix value = + if String.starts_with ~prefix value then + String.sub value (String.length prefix) + (String.length value - String.length prefix) + else value + +let remove_suffix suffix value = + if String.ends_with ~suffix value then + String.sub value 0 (String.length value - String.length suffix) + else value + +let issues_url_from_repository repository = + let cleaned = repository |> remove_prefix "git+" |> remove_suffix ".git" in + if + (not (String.contains cleaned '@')) + && not (String_util.contains cleaned "://") + then + let path = remove_prefix "github:" cleaned in + "https://github.com/" ^ path ^ "/issues" + else cleaned ^ "/issues" + +let issue_tracker_url package_root = + let path = Filename.concat package_root "package.json" in + try + let json = Yojson.Safe.from_file path in + match Option.bind (member "bugs" json) url_value with + | Some url -> Some url + | None -> + Option.bind (member "repository" json) url_value + |> Option.map issues_url_from_repository + with Sys_error _ | Yojson.Json_error _ -> None + +let for_package ~is_local (config : Config.t) = + if is_local then config.diagnostics + else if config.deprecation_diagnostics = [] then [] + else + let report_suffix = + issue_tracker_url config.root + |> Option.map (fun url -> + "\nPlease report this to the package maintainer: " ^ url) + |> Option.value ~default:"" + in + List.map + (fun diagnostic -> diagnostic ^ report_suffix) + config.deprecation_diagnostics + +let report_missing_source_folder (config : Config.t) path = + let prefix = Filename.concat config.root "" in + let relative = + if String.starts_with ~prefix path then + String.sub path (String.length prefix) + (String.length path - String.length prefix) + else path + in + Printf.eprintf + "ERROR:\n\ + Could not read folder: %S. Specified in dependency: %s, located %S...\n\ + %!" + relative config.name config.root + +let report_missing_sources ~is_root (config : Config.t) = + if (not is_root) && not config.sources_defined then + Printf.eprintf + "WARN:\n\ + Package '%s' has not defined any sources, but is not the root package. \ + This is likely a mistake. It is located: %s\n\ + %!" + config.name config.root + +let package_identity ~report_diagnostics (config : Config.t) = + match package_name config.root with + | Error message -> + raise (Project_context.Error ("Could not initialize build: " ^ message)) + | Ok (Some package_name) when package_name <> config.name -> + if report_diagnostics then + Printf.eprintf + "WARN:\n\n\ + Package name mismatch for %s:\n\ + The package.json name is %S, while the rescript.json name is %S\n\ + This inconsistency will cause issues with package resolution.\n\n\ + %!" + config.root package_name config.name; + package_name + | Ok (Some package_name) -> package_name + | Ok None -> config.name + +let validate_metadata config = + ignore (package_identity ~report_diagnostics:true config) + +module For_test = struct + let package_name = package_name + let issue_tracker_url = issue_tracker_url +end diff --git a/rewatch-ocaml/package_diagnostics.mli b/rewatch-ocaml/package_diagnostics.mli new file mode 100644 index 00000000000..8bcccf0057f --- /dev/null +++ b/rewatch-ocaml/package_diagnostics.mli @@ -0,0 +1,10 @@ +val for_package : is_local:bool -> Config.t -> string list +val report_missing_source_folder : Config.t -> string -> unit +val report_missing_sources : is_root:bool -> Config.t -> unit +val package_identity : report_diagnostics:bool -> Config.t -> string +val validate_metadata : Config.t -> unit + +module For_test : sig + val package_name : string -> (string option, string) result + val issue_tracker_url : string -> string option +end diff --git a/rewatch-ocaml/package_graph.ml b/rewatch-ocaml/package_graph.ml new file mode 100644 index 00000000000..9e866cba602 --- /dev/null +++ b/rewatch-ocaml/package_graph.ml @@ -0,0 +1,183 @@ +exception Error = Project_context.Error + +let with_gentype_source_dirs directories (config : Config.t) = + if config.gentype_args = [] then config + else + { + config with + gentype_args = + config.gentype_args + @ List.concat_map + (fun directory -> ["-bs-gentype-source-dir"; directory]) + directories; + } + +let dependent_is_allowed allowed_dependents dependent = + Option.fold ~none:true + ~some:(fun allowed -> List.mem dependent allowed) + allowed_dependents + +let discover ~(root_config : Config.t) ~prod ~features ~warn_error ~filter + ~(attempt : Build_attempt.t) = + let resolution = Package_resolution.create root_config in + let unallowed_dependencies = ref [] in + let discovered = + Package_traversal.discover ~root_config ~prod ~features ~resolution + in + List.iter + (fun (package : Package_traversal.package) -> + Output.debug ~verbosity:attempt.verbosity + ("Parsing package: " ^ package.config.name); + List.iter + (fun (resolved : Package_traversal.resolved) -> + let dependency = resolved.dependency in + if + not + (dependent_is_allowed dependency.config.allowed_dependents + package.config.name) + then + unallowed_dependencies := + ( package.config.name, + Package_traversal.dependency_kind_name resolved.request.kind, + dependency.config.name ) + :: !unallowed_dependencies) + package.dependencies) + discovered.packages; + (if !unallowed_dependencies <> [] then + let details = + !unallowed_dependencies |> List.sort_uniq compare + |> List.map (fun (dependent, kind, dependency) -> + Printf.sprintf "%s %s: %s" dependent kind dependency) + |> String.concat "\n" + in + raise + (Error + ("The following packages use dependencies that do not allow them:\n" + ^ details + ^ "\nUpdate allowed-dependents in the dependency rescript.json files." + ))); + let packages_by_root = Hashtbl.create (List.length discovered.packages) in + List.iter + (fun (package : Package_traversal.package) -> + Hashtbl.add packages_by_root package.config.root package) + discovered.packages; + let visited = Hashtbl.create 32 in + let package_plans = ref [] in + let rec visit root = + if not (Hashtbl.mem visited root) then ( + Hashtbl.add visited root (); + let discovered_package = Hashtbl.find packages_by_root root in + let is_local = discovered_package.Package_traversal.is_local in + let features = + match Package_traversal.find_feature_selection discovered root with + | Some features -> + Package_traversal.feature_selection_to_option features + | None -> None + in + let config = discovered_package.config in + Package_diagnostics.report_missing_sources + ~is_root:(root = root_config.root) config; + let config = + match warn_error with + | None -> config + | Some value -> {config with warning_flags = ["-warn-error"; value]} + in + let dependencies = + List.map + (fun (resolved : Package_traversal.resolved) -> + let request = resolved.request in + Package_plan. + { + declaration = request.declaration; + directory = resolved.dependency.directory; + kind = request.kind; + }) + discovered_package.dependencies + in + List.iter + (fun (dependency : Package_plan.dependency) -> + visit dependency.directory) + dependencies; + let discovery = + Output.debug ~verbosity:attempt.verbosity + ("Building source file-tree for package: " ^ config.name); + Source.discover_with_inventory config + ~prod:(Package_traversal.source_discovery_prod ~prod ~is_local) + ~features + ~filter:(if root = root_config.root then filter else None) + ~on_missing:(Package_diagnostics.report_missing_source_folder config) + ~on_orphan:(fun path -> + Printf.eprintf + "\027[2K\r No implementation file found for interface file \ + (skipping): %s\n\ + %!" + path) + ~display_root:root_config.root + in + let modules = discovery.modules in + let owns_outputs = + root <> root_config.root && Compiler_info.owns_outputs config + in + let compile_config = + let config = with_gentype_source_dirs discovery.gentype_dirs config in + let inherited = Config.with_root_options config root_config in + let output_config = + if owns_outputs then + { + inherited with + package_specs = config.package_specs; + suffix = config.suffix; + } + else inherited + in + output_config |> Compiler_args.with_local_warning_policy ~is_local + in + let build_dir = Build_artifacts.lib_path root "bs" in + let ocaml_dir = Build_artifacts.lib_path root "ocaml" in + File_util.ensure_dir build_dir; + let source_mtimes = + Hashtbl.create (List.length discovery.source_mtimes) + in + List.iter + (fun (path, modified) -> Hashtbl.replace source_mtimes path modified) + discovery.source_mtimes; + let package : Package_plan.t = + Package_plan. + { + name = discovered_package.name; + root; + build_owner = (if owns_outputs then root else root_config.root); + is_local; + config; + compile_config; + build_dir; + ocaml_dir; + dependencies; + gentype_dependency_args = + Compiler_args.gentype_dependency_args_from_paths compile_config + (List.map + (fun (dependency : Package_plan.dependency) -> + (dependency.declaration, dependency.directory)) + dependencies); + modules; + source_mtimes; + source_files = discovery.inventory_files; + present_source_files = discovery.present_files; + } + in + Build_session.add_package_plan attempt.session package; + List.iter + (fun module_ -> + module_.Source.implementation + :: Option.to_list module_.Source.interface + |> List.iter (fun relative_path -> + let absolute_path = Filename.concat root relative_path in + Build_session.add_source_reference attempt.session + (Platform.normalize_path_for_comparison absolute_path) + Build_session. + {package_root = root; module_; relative_path; absolute_path})) + modules; + package_plans := package :: !package_plans) + in + visit root_config.root; + !package_plans diff --git a/rewatch-ocaml/package_graph.mli b/rewatch-ocaml/package_graph.mli new file mode 100644 index 00000000000..20647927aa8 --- /dev/null +++ b/rewatch-ocaml/package_graph.mli @@ -0,0 +1,12 @@ +val discover : + root_config:Config.t -> + prod:bool -> + features:string list option -> + warn_error:string option -> + filter:Source_filter.t option -> + attempt:Build_attempt.t -> + Package_plan.t list +(** Package discovery resolves each dependency edge once, aggregates feature + requests across all incoming edges, and then constructs stable package + plans. Commands that need a different file projection reuse the lower-level + {!Package_traversal} graph. *) diff --git a/rewatch-ocaml/package_parse.ml b/rewatch-ocaml/package_parse.ml new file mode 100644 index 00000000000..ac7e31f0821 --- /dev/null +++ b/rewatch-ocaml/package_parse.ml @@ -0,0 +1,113 @@ +let run ~(package : Package_plan.t) ~(prepared : Build_session.prepared) + ~(prepared_package : Package_plan.compilation) ~(attempt : Build_attempt.t) + ~removed_module_names = + let root = package.root in + let is_local = package.is_local in + let config = package.compile_config in + let build_state = prepared.build_state in + let compile_assets = prepared.compile_assets in + let build_dir = package.build_dir in + let ocaml_dir = package.ocaml_dir in + let dirty_parse_paths = + prepared_package.parse_paths + |> List.filter (fun path -> + let forced = + Hashtbl.mem attempt.preliminary_parses (Filename.concat root path) + in + match attempt.freshness_mode with + | Build_attempt.Reuse_freshness -> forced + | Build_attempt.Initialize_freshness -> + Hashtbl.mem removed_module_names (Source.module_name path) + || forced + || Build_freshness.source_is_not_older_than_ast compile_assets ~root + ~source_mtimes:package.source_mtimes path) + in + let dirty_parse_path_set = Hashtbl.create (List.length dirty_parse_paths) in + List.iter + (fun path -> Hashtbl.replace dirty_parse_path_set path ()) + dirty_parse_paths; + List.iter + (fun path -> + Filename.concat root path |> Platform.normalize_path_for_comparison + |> Build_session.mark_parse_pending attempt.session) + dirty_parse_paths; + let dirty_modules = Hashtbl.create (List.length package.modules) in + List.iter + (fun module_ -> + let paths = + module_.Source.implementation :: Option.to_list module_.Source.interface + in + if List.exists (Hashtbl.mem dirty_parse_path_set) paths then ( + Hashtbl.replace dirty_modules module_.Source.name (); + let key = Source.compiler_basename config module_.Source.name in + (Build_state.find_exn build_state key).compile_dirty <- true)) + package.modules; + let parse_paths_to_run = + dirty_parse_paths + |> List.filter (fun path -> + not (Hashtbl.mem attempt.preliminary_parses (Filename.concat root path))) + in + let parsed = + List.map2 + (fun path result -> (path, Build_attempt.preliminary_parse result)) + parse_paths_to_run + (Process.run_parallel_map ?poll:attempt.process_poll parse_paths_to_run + ~job: + (Compiler_process.parse_job ~bsc:prepared.compiler_context.bsc_path + ~build_dir ~config)) + @ (dirty_parse_paths + |> List.filter_map (fun path -> + Hashtbl.find_opt attempt.preliminary_parses + (Filename.concat root path) + |> Option.map (fun result -> (path, result)))) + in + List.iter + (fun (path, result) -> + let absolute_path = Filename.concat root path in + let pending_path = Platform.normalize_path_for_comparison absolute_path in + let publish_successful_parse stderr = + let stderr = + if is_local then stderr + else Compiler_process.retain_critical_external_warnings stderr + in + if stderr <> "" then ( + attempt.had_warnings <- true; + Compiler_log.append root stderr; + attempt.parse_messages <- + Build_attempt.Parse_warning stderr :: attempt.parse_messages); + let ast = Source.ast_path path in + if is_local && stderr <> "" then + Build_attempt.register_cleanup attempt (fun () -> + let path = Filename.concat ocaml_dir (Filename.basename ast) in + File_util.remove_file path; + Compile_assets.refresh_ast compile_assets ~source:absolute_path + ~path); + let published_ast = + Build_artifacts.published_ast_path ~ocaml_dir path + in + File_util.copy_existing_file ~ensure_parent:false + (Filename.concat build_dir ast) + published_ast; + Compile_assets.refresh_ast compile_assets ~source:absolute_path + ~path:published_ast; + File_util.copy_existing_file ~ensure_parent:false + (Filename.concat config.root path) + (Filename.concat ocaml_dir (Filename.basename path)); + if is_local && stderr <> "" then + Build_session.mark_parse_pending attempt.session pending_path + else Build_session.clear_parse_pending attempt.session pending_path + in + match result with + | Build_attempt.Parse_failed {stdout; stderr} -> + Build_session.mark_parse_pending attempt.session pending_path; + let output = + Printf.sprintf "Error in %s:\n%s%s" config.name stderr stdout + in + Compiler_log.append root output; + attempt.parse_messages <- + Build_attempt.Parse_error output :: attempt.parse_messages + | Build_attempt.Parsed_successfully {stderr} -> + publish_successful_parse stderr + | Build_attempt.Use_existing_ast -> publish_successful_parse "") + parsed; + dirty_modules diff --git a/rewatch-ocaml/package_parse.mli b/rewatch-ocaml/package_parse.mli new file mode 100644 index 00000000000..8b625e96374 --- /dev/null +++ b/rewatch-ocaml/package_parse.mli @@ -0,0 +1,11 @@ +val run : + package:Package_plan.t -> + prepared:Build_session.prepared -> + prepared_package:Package_plan.compilation -> + attempt:Build_attempt.t -> + removed_module_names:(string, unit) Hashtbl.t -> + (string, unit) Hashtbl.t +(** Parsing returns the modules whose dependency information changed or whose + compilation is pending. Failed and warning-bearing source paths are also + retained in the session so later watch attempts cannot silently reuse an + older AST. *) diff --git a/rewatch-ocaml/package_plan.ml b/rewatch-ocaml/package_plan.ml new file mode 100644 index 00000000000..01b6ab66c10 --- /dev/null +++ b/rewatch-ocaml/package_plan.ml @@ -0,0 +1,28 @@ +type dependency = { + declaration: Config.dependency; + directory: string; + kind: Package_traversal.dependency_kind; +} + +type t = { + name: string; + root: string; + build_owner: string; + is_local: bool; + config: Config.t; + compile_config: Config.t; + build_dir: string; + ocaml_dir: string; + dependencies: dependency list; + gentype_dependency_args: string list; + modules: Source.module_ list; + source_mtimes: (string, float) Hashtbl.t; + source_files: string list; + present_source_files: string list; +} + +type compilation = { + regular_common_args: string list; + development_common_args: string list; + parse_paths: string list; +} diff --git a/rewatch-ocaml/package_plan.mli b/rewatch-ocaml/package_plan.mli new file mode 100644 index 00000000000..163f79212ac --- /dev/null +++ b/rewatch-ocaml/package_plan.mli @@ -0,0 +1,32 @@ +(** A package plan freezes discovery and configuration decisions that are + stable across retained watch attempts. Compilation-specific arguments are + stored separately so per-attempt dirtiness does not require rediscovery. *) + +type dependency = { + declaration: Config.dependency; + directory: string; + kind: Package_traversal.dependency_kind; +} + +type t = { + name: string; + root: string; + build_owner: string; + is_local: bool; + config: Config.t; + compile_config: Config.t; + build_dir: string; + ocaml_dir: string; + dependencies: dependency list; + gentype_dependency_args: string list; + modules: Source.module_ list; + source_mtimes: (string, float) Hashtbl.t; + source_files: string list; + present_source_files: string list; +} + +type compilation = { + regular_common_args: string list; + development_common_args: string list; + parse_paths: string list; +} diff --git a/rewatch-ocaml/package_resolution.ml b/rewatch-ocaml/package_resolution.ml new file mode 100644 index 00000000000..d61a0d9c957 --- /dev/null +++ b/rewatch-ocaml/package_resolution.ml @@ -0,0 +1,121 @@ +type dependency = { + name: string; + directory: string; + config: Config.t; + is_local: bool; +} + +type diagnostic_mode = Report_diagnostics | Suppress_diagnostics + +type t = { + root_config: Config.t; + context: Project_context.dependency_context; + loaded: (string, Config.t) Hashtbl.t; + edges: (string * string, dependency) Hashtbl.t; + selected: (string, dependency) Hashtbl.t; + reported_duplicates: (string * string, unit) Hashtbl.t; + diagnostic_mode: diagnostic_mode; + root_package_name: string; +} + +let create ?(diagnostic_mode = Report_diagnostics) root_config = + let loaded = Hashtbl.create 32 in + Hashtbl.add loaded root_config.Config.root root_config; + { + root_config; + context = Project_context.dependency_context root_config; + loaded; + edges = Hashtbl.create 32; + selected = Hashtbl.create 32; + reported_duplicates = Hashtbl.create 8; + diagnostic_mode; + root_package_name = + Package_diagnostics.package_identity + ~report_diagnostics:(diagnostic_mode = Report_diagnostics) + root_config; + } + +let load_config resolution root = + match Hashtbl.find_opt resolution.loaded root with + | Some config -> config + | None -> + let config = Config.load_root root in + Hashtbl.add resolution.loaded root config; + config + +let is_local resolution directory = + Project_context.dependency_is_local_canonical resolution.context directory + +let dependency_path resolution ~package_root name = + Project_context.dependency_path_in resolution.context package_root name + +let dependency_candidates resolution ~package_root name = + Project_context.dependency_candidates_in resolution.context package_root name + +let root_package_name resolution = resolution.root_package_name + +let resolve resolution ~package_root (declaration : Config.dependency) = + let edge_key = (package_root, declaration.name) in + match Hashtbl.find_opt resolution.edges edge_key with + | Some identity -> identity + | None -> + let candidate = + Project_context.require_dependency_directory ~context:resolution.context + package_root declaration + in + let dependency = + match Hashtbl.find_opt resolution.selected declaration.name with + | Some selected -> + (if selected.directory <> candidate then + let key = (declaration.name, candidate) in + if + resolution.diagnostic_mode = Report_diagnostics + && not (Hashtbl.mem resolution.reported_duplicates key) + then ( + Hashtbl.add resolution.reported_duplicates key (); + Printf.eprintf "Duplicated package: %s %s (chosen) vs %s in %s\n%!" + declaration.name + (Project_context.display_path ~root:resolution.root_config.root + selected.directory) + (Project_context.display_path ~root:resolution.root_config.root + candidate) + (Project_context.display_path ~root:resolution.root_config.root + package_root))); + selected + | None -> + let config = + try load_config resolution candidate + with Config.Error message -> + raise + (Project_context.Package_error + (Printf.sprintf + "Could not build package tree for '%s' at path '%s'. \ + Error: %s" + declaration.name resolution.root_config.root message)) + in + let name = + Package_diagnostics.package_identity + ~report_diagnostics:(resolution.diagnostic_mode = Report_diagnostics) + config + in + if name <> declaration.name then + raise + (Project_context.Package_error + (Printf.sprintf + "Could not build package tree reading dependency '%s' at \ + path '%s'. Error: resolved package identity '%s' does not \ + match the requested dependency name" + declaration.name candidate name)); + let identity = + { + name; + directory = candidate; + config; + is_local = is_local resolution candidate; + } + in + Hashtbl.add resolution.selected declaration.name identity; + identity + in + Hashtbl.add resolution.edges edge_key dependency; + dependency diff --git a/rewatch-ocaml/package_resolution.mli b/rewatch-ocaml/package_resolution.mli new file mode 100644 index 00000000000..71f0d30bc04 --- /dev/null +++ b/rewatch-ocaml/package_resolution.mli @@ -0,0 +1,20 @@ +type t +(** Resolution caches package identity, not declaration-specific feature + requests. The same installed package may be reached through multiple edges + whose requested features must remain distinct for later aggregation. *) + +type dependency = { + name: string; + directory: string; + config: Config.t; + is_local: bool; +} + +type diagnostic_mode = Report_diagnostics | Suppress_diagnostics + +val create : ?diagnostic_mode:diagnostic_mode -> Config.t -> t +val resolve : t -> package_root:string -> Config.dependency -> dependency +val dependency_path : t -> package_root:string -> string -> string option +val dependency_candidates : t -> package_root:string -> string -> string list +val root_package_name : t -> string +val is_local : t -> string -> bool diff --git a/rewatch-ocaml/package_traversal.ml b/rewatch-ocaml/package_traversal.ml new file mode 100644 index 00000000000..46c96042ac9 --- /dev/null +++ b/rewatch-ocaml/package_traversal.ml @@ -0,0 +1,111 @@ +type dependency_kind = Regular | Development + +let source_discovery_prod ~prod ~is_local = prod || not is_local + +type request = {kind: dependency_kind; declaration: Config.dependency} + +type resolved = {request: request; dependency: Package_resolution.dependency} + +type package = { + name: string; + config: Config.t; + is_local: bool; + dependencies: resolved list; +} + +type feature_selection = All_features | Selected_features of string list +module String_set = Set.Make (String) + +type accumulated_features = All_requested | Selected_requested of String_set.t + +type feature_requests = (string, accumulated_features) Hashtbl.t +type graph = {packages: package list; feature_requests: feature_requests} + +let add_feature_request requests root request = + match (Hashtbl.find_opt requests root, request) with + | None, None -> Hashtbl.add requests root All_requested + | None, Some requested -> + Hashtbl.add requests root + (Selected_requested (String_set.of_list requested)) + | Some All_requested, _ | Some _, None -> + Hashtbl.replace requests root All_requested + | Some (Selected_requested current), Some requested -> + Hashtbl.replace requests root + (Selected_requested + (List.fold_left + (fun features feature -> String_set.add feature features) + current requested)) + +let find_feature_selection graph root = + Hashtbl.find_opt graph.feature_requests root + |> Option.map (function + | All_requested -> All_features + | Selected_requested features -> + Selected_features (String_set.elements features)) + +let feature_selection_to_option = function + | All_features -> None + | Selected_features features -> Some features + +let dependency_kind_name = function + | Regular -> "dependencies" + | Development -> "dev-dependencies" + +let requests ~prod ~is_local (config : Config.t) = + List.map + (fun declaration -> {kind = Regular; declaration}) + config.dependencies + @ + if prod || not is_local then [] + else + List.map + (fun declaration -> {kind = Development; declaration}) + config.dev_dependencies + +let resolve resolution ~package_root request = + { + request; + dependency = + Package_resolution.resolve resolution ~package_root request.declaration; + } + +let traverse ~root_config ~root_name ~prod ~features ~resolve = + let visited = Hashtbl.create 32 in + let feature_requests = Hashtbl.create 32 in + let packages = ref [] in + let rec visit ~name ~is_local ~features (config : Config.t) = + add_feature_request feature_requests config.root features; + if not (Hashtbl.mem visited config.root) then ( + Hashtbl.add visited config.root (); + let dependencies = + requests ~prod ~is_local config + |> List.filter_map (fun request -> resolve config request) + in + List.iter + (fun resolved -> + visit ~is_local:resolved.dependency.is_local + ~name:resolved.dependency.name + ~features:resolved.request.declaration.features + resolved.dependency.config) + dependencies; + packages := {name; config; is_local; dependencies} :: !packages) + in + visit ~name:root_name ~is_local:true ~features root_config; + {packages = !packages; feature_requests} + +let discover ~root_config ~prod ~features ~resolution = + traverse ~root_config + ~root_name:(Package_resolution.root_package_name resolution) ~prod ~features + ~resolve:(fun config request -> + Some (resolve resolution ~package_root:config.root request)) + +module For_test = struct + let create_feature_requests () = Hashtbl.create 4 + let add_feature_request = add_feature_request + let find_feature_selection requests root = + Hashtbl.find_opt requests root + |> Option.map (function + | All_requested -> All_features + | Selected_requested features -> + Selected_features (String_set.elements features)) +end diff --git a/rewatch-ocaml/package_traversal.mli b/rewatch-ocaml/package_traversal.mli new file mode 100644 index 00000000000..bf8bb49ded5 --- /dev/null +++ b/rewatch-ocaml/package_traversal.mli @@ -0,0 +1,53 @@ +(** Traversal centralizes dependency admission and feature aggregation while + leaving command-specific failure policy to its [resolve] callback. This is + why build, watch, and format can share graph semantics without sharing all + recovery behavior. *) +type dependency_kind = Regular | Development + +val source_discovery_prod : prod:bool -> is_local:bool -> bool + +type request = {kind: dependency_kind; declaration: Config.dependency} + +type resolved = {request: request; dependency: Package_resolution.dependency} + +type package = { + name: string; + config: Config.t; + is_local: bool; + dependencies: resolved list; +} + +type feature_selection = All_features | Selected_features of string list +type feature_requests +type graph = {packages: package list; feature_requests: feature_requests} + +val find_feature_selection : graph -> string -> feature_selection option +val feature_selection_to_option : feature_selection -> string list option + +val dependency_kind_name : dependency_kind -> string +val requests : prod:bool -> is_local:bool -> Config.t -> request list + +val resolve : Package_resolution.t -> package_root:string -> request -> resolved + +val traverse : + root_config:Config.t -> + root_name:string -> + prod:bool -> + features:string list option -> + resolve:(Config.t -> request -> resolved option) -> + graph + +val discover : + root_config:Config.t -> + prod:bool -> + features:string list option -> + resolution:Package_resolution.t -> + graph + +module For_test : sig + val create_feature_requests : unit -> feature_requests + val add_feature_request : + feature_requests -> string -> string list option -> unit + val find_feature_selection : + feature_requests -> string -> feature_selection option +end diff --git a/rewatch-ocaml/platform.mli b/rewatch-ocaml/platform.mli new file mode 100644 index 00000000000..d6711f9f2ea --- /dev/null +++ b/rewatch-ocaml/platform.mli @@ -0,0 +1,55 @@ +(** Platform-specific filesystem and process operations live behind this + interface because path identity, command-line quoting, handle inheritance, + and process-tree termination have materially different Unix and Windows + contracts. Higher layers should not infer those rules from path strings. *) + +val normalize_path_for_comparison : string -> string +val directory_identity : path:string -> Unix.stats -> string +val canonicalize_path : string -> string +val resolve_program : cwd:string -> string -> string +val terminal_supports_color_without_term : bool +val inherit_streaming_terminal_stdin : bool +val configure_standard_streams : unit -> unit +val clean_symbol : string +val parse_symbol : string +val build_symbol : string +val success_symbol : string +val warning_symbol : string +val error_symbol : string + +type command = {env: Spawn.Env.t option; program: string; args: string list} + +val post_build_command : command:string -> output:string -> command + +type process + +val spawn : + env:Spawn.Env.t option -> + cwd:string -> + program:string -> + args:string list -> + stdin:Unix.file_descr -> + stdout:Unix.file_descr -> + stderr:Unix.file_descr -> + process + +val null_device : string + +val current_process_id : unit -> int +val process_id : process -> int +val release_process : process -> unit + +val create_capture_pipes : + unit -> + (Unix.file_descr * Unix.file_descr) * (Unix.file_descr * Unix.file_descr) + +val signal_process_tree : + root_reaped:bool -> process -> int -> (unit, string) result +val defer_termination_signals : unit -> unit -> unit +val graceful_termination_signal : int +val escalate_process_groups : bool + +val process_is_active : + run:(string -> string list -> (Unix.process_status * string) option) -> + string -> + bool diff --git a/rewatch-ocaml/platform_common.ml b/rewatch-ocaml/platform_common.ml new file mode 100644 index 00000000000..0ad629386fe --- /dev/null +++ b/rewatch-ocaml/platform_common.ml @@ -0,0 +1,41 @@ +let resolve_program ~path_separator ~executable_extensions ~search_directories + ~normalize_directory ~executable_is_usable ~cwd program = + if (not (Filename.is_implicit program)) || Filename.dirname program <> "." + then program + else + let path_directories = + Sys.getenv_opt "PATH" |> Option.value ~default:"" + |> String.split_on_char path_separator + in + search_directories ~cwd path_directories + |> List.find_map (fun directory -> + let directory = normalize_directory directory in + let directory = + if directory = "" then cwd + else if Filename.is_relative directory then + Filename.concat cwd directory + else directory + in + executable_extensions ~program + |> List.find_map (fun extension -> + let candidate = Filename.concat directory (program ^ extension) in + if executable_is_usable candidate then Some candidate else None)) + |> Option.value ~default:program + +let process_is_active ~probe value = + try + let pid = int_of_string value in + (* PID zero names a process group on Unix and is not a usable child-process + identity on Windows, so it can never prove ownership of a build lock. *) + if pid = 0 then false else probe pid + with + | Failure _ | Unix.Unix_error (Unix.ESRCH, _, _) -> false + | Unix.Unix_error (Unix.EPERM, _, _) -> true + +let create_capture_pipes () = + let stdout = Spawn.safe_pipe () in + try (stdout, Spawn.safe_pipe ()) + with exn -> + Unix.close (fst stdout); + Unix.close (snd stdout); + raise exn diff --git a/rewatch-ocaml/platform_common.mli b/rewatch-ocaml/platform_common.mli new file mode 100644 index 00000000000..49a6e78659c --- /dev/null +++ b/rewatch-ocaml/platform_common.mli @@ -0,0 +1,14 @@ +val resolve_program : + path_separator:char -> + executable_extensions:(program:string -> string list) -> + search_directories:(cwd:string -> string list -> string list) -> + normalize_directory:(string -> string) -> + executable_is_usable:(string -> bool) -> + cwd:string -> + string -> + string + +val process_is_active : probe:(int -> bool) -> string -> bool +val create_capture_pipes : + unit -> + (Unix.file_descr * Unix.file_descr) * (Unix.file_descr * Unix.file_descr) diff --git a/rewatch-ocaml/platform_unix.ml b/rewatch-ocaml/platform_unix.ml new file mode 100644 index 00000000000..b3ab49f354e --- /dev/null +++ b/rewatch-ocaml/platform_unix.ml @@ -0,0 +1,105 @@ +let path_separator = ':' +let terminal_supports_color_without_term = false +let inherit_streaming_terminal_stdin = false +let configure_standard_streams () = () +let clean_symbol = "🧹 " +let parse_symbol = "🧱 " +let build_symbol = "🤺 " +let success_symbol = "✅ " +let warning_symbol = "⚠️ " +let error_symbol = "❌ " +let normalize_path_for_comparison value = value +let directory_identity ~path:_ metadata = + Printf.sprintf "%d:%d" metadata.Unix.st_dev metadata.Unix.st_ino + +let canonicalize_path = Unix.realpath +let executable_extensions ~program:_ = [""] +let search_directories ~cwd:_ directories = directories + +let executable_is_usable candidate = + try + (Unix.stat candidate).Unix.st_kind = Unix.S_REG + && + try + Unix.access candidate [Unix.X_OK]; + true + with Unix.Unix_error _ -> false + with Unix.Unix_error _ -> false + +let resolve_program = + Platform_common.resolve_program ~path_separator ~executable_extensions + ~search_directories ~normalize_directory:Fun.id ~executable_is_usable + +type command = {env: Spawn.Env.t option; program: string; args: string list} + +let post_build_command ~command ~output = + { + env = None; + program = "/bin/sh"; + args = ["-c"; command ^ " " ^ Filename.quote output]; + } + +type process = int + +let spawn ~env ~cwd ~program ~args ~stdin ~stdout ~stderr = + let program = resolve_program ~cwd program in + Spawn.spawn ?env ~cwd:(Spawn.Working_dir.Path cwd) ~prog:program + ~argv:(program :: args) ~stdin ~stdout ~stderr + ~setpgid:Spawn.Pgid.new_process_group () + +let null_device = "/dev/null" + +let current_process_id = Unix.getpid +let process_id process = process +let release_process _process = () + +let create_capture_pipes = Platform_common.create_capture_pipes + +let rec signal_process_tree ~root_reaped process signal = + try + Unix.kill (-process) signal; + Ok () + with + (* A missing group is already in the requested terminal state. Other errors + must prevent the caller from waiting indefinitely for an undelivered + signal. *) + | Unix.Unix_error (Unix.ESRCH, _, _) -> Ok () + | Unix.Unix_error (Unix.EINTR, _, _) -> + signal_process_tree ~root_reaped process signal + | Unix.Unix_error (error, operation, _) -> + Error + (Printf.sprintf "%s while sending signal %d to process group %d: %s" + operation signal process (Unix.error_message error)) + +let defer_termination_signals () = + let previous = Unix.sigprocmask Unix.SIG_BLOCK [Sys.sigint; Sys.sigterm] in + fun () -> ignore (Unix.sigprocmask Unix.SIG_SETMASK previous) + +let graceful_termination_signal = Sys.sigterm +let escalate_process_groups = true + +let process_name_from_ps ~run pid = + match run "/bin/ps" ["-p"; string_of_int pid; "-o"; "comm="] with + | Some (Unix.WEXITED 0, output) -> + output |> String.trim |> Filename.basename + |> String.starts_with ~prefix:"rescript" + | Some (Unix.WEXITED _, _) -> false + | Some (Unix.WSIGNALED _, _) | Some (Unix.WSTOPPED _, _) | None -> true + +let probe_process ~run pid = + Unix.kill pid 0; + let executable = Printf.sprintf "/proc/%d/exe" pid in + if Sys.file_exists executable then + try + let basename = Unix.realpath executable |> Filename.basename in + String.starts_with ~prefix:"rescript" basename + with Unix.Unix_error _ -> true + else + (* macOS has no procfs. `ps` supplies the same executable-name check so a + reused PID from an abandoned lock is not mistaken for this tool. A + failed probe remains conservative because stealing a live build's lock + is worse than asking the user to remove an inconclusive stale lock. *) + process_name_from_ps ~run pid + +let process_is_active ~run value = + Platform_common.process_is_active ~probe:(probe_process ~run) value diff --git a/rewatch-ocaml/platform_windows.ml b/rewatch-ocaml/platform_windows.ml new file mode 100644 index 00000000000..de7716ddce5 --- /dev/null +++ b/rewatch-ocaml/platform_windows.ml @@ -0,0 +1,291 @@ +let path_separator = ';' +let terminal_supports_color_without_term = true +let inherit_streaming_terminal_stdin = true + +external enable_virtual_terminal_processing : unit -> unit + = "rewatch_windows_enable_virtual_terminal_processing" + +let configure_standard_streams () = + set_binary_mode_in stdin true; + set_binary_mode_out stdout true; + set_binary_mode_out stderr true; + enable_virtual_terminal_processing () +let clean_symbol = "[clean] " +let parse_symbol = "[parse] " +let build_symbol = "[build] " +let success_symbol = "[ok] " +let warning_symbol = "[warn] " +let error_symbol = "[error] " +let normalize_path_for_comparison path = + path |> String.lowercase_ascii + |> String.map (function + | '/' -> '\\' + | character -> character) + +let strip_verbatim_prefix path = + if String.starts_with ~prefix:"\\\\?\\UNC\\" path then + "\\\\" ^ String.sub path 8 (String.length path - 8) + else if String.starts_with ~prefix:"\\\\?\\" path then + String.sub path 4 (String.length path - 4) + else path + +let canonicalize_path path = Unix.realpath path |> strip_verbatim_prefix + +external directory_file_identity : string -> string + = "rewatch_windows_directory_identity" + +let directory_identity ~path _metadata = directory_file_identity path + +let executable_extensions ~program = + if Filename.extension program <> "" then [""] + else + Sys.getenv_opt "PATHEXT" + |> Option.value ~default:".COM;.EXE;.BAT;.CMD" + |> String.split_on_char ';' + +let search_directories ~cwd directories = cwd :: directories + +let executable_is_usable candidate = + try (Unix.stat candidate).Unix.st_kind = Unix.S_REG + with Unix.Unix_error _ -> false + +let normalize_path_directory directory = + let directory = String.trim directory in + let length = String.length directory in + if length >= 2 && directory.[0] = '"' && directory.[length - 1] = '"' then + String.sub directory 1 (length - 2) + else directory + +let resolve_program = + Platform_common.resolve_program ~path_separator ~executable_extensions + ~search_directories ~normalize_directory:normalize_path_directory + ~executable_is_usable + +type command = {env: Spawn.Env.t option; program: string; args: string list} + +let post_build_command ~command ~output = + let variable = "REWATCH_JS_POST_BUILD_FILE" in + let prefix = String.lowercase_ascii (variable ^ "=") in + let environment = + Unix.environment () |> Array.to_list + |> List.filter (fun entry -> + not (String.starts_with ~prefix (String.lowercase_ascii entry))) + |> List.cons (variable ^ "=" ^ output) + |> Spawn.Env.of_list + in + { + env = Some environment; + program = "cmd.exe"; + args = ["/D"; "/V:OFF"; "/S"; "/C"; command ^ " \"%" ^ variable ^ "%\""]; + } + +let is_batch_file program = + List.mem + (Filename.extension program |> String.lowercase_ascii) + [".bat"; ".cmd"] + +type process_job + +type process = {wait_id: int; job: process_job} + +external spawn_owned : + env:Spawn.Env.t option -> + cwd:string -> + program:string -> + command_line:string -> + stdin:Unix.file_descr -> + stdout:Unix.file_descr -> + stderr:Unix.file_descr -> + process = "rewatch_windows_spawn_owned_byte" "rewatch_windows_spawn_owned" + +external terminate_process_job : process_job -> bool + = "rewatch_windows_terminate_process_job" + +external close_process_job : process_job -> unit + = "rewatch_windows_close_process_job" + +let quote_argument argument = + if + argument = "" + || String.contains argument ' ' + || String.contains argument '\t' + || String.contains argument '"' + then Filename.quote argument + else argument + +let ensure_no_null label value = + if String.contains value '\x00' then + invalid_arg (Printf.sprintf "%s contains a NUL byte" label) + +let program_for_working_directory ~cwd program = + if Filename.is_relative program then Filename.concat cwd program else program + +let serialize_command_line ~shell_command_is_quoted ~program ~args = + match args with + | ["/D"; "/V:OFF"; "/S"; "/C"; command] -> + let command = + if shell_command_is_quoted then command else "\"" ^ command ^ "\"" + in + String.concat " " [quote_argument program; "/D"; "/V:OFF"; "/S"; "/C"] + ^ " " ^ command + | _ -> program :: args |> List.map quote_argument |> String.concat " " + +let spawn ~env ~cwd ~program ~args ~stdin ~stdout ~stderr = + let program = resolve_program ~cwd program in + ensure_no_null "working directory" cwd; + ensure_no_null "program" program; + List.iter (ensure_no_null "argument") args; + let program, args, shell_command_is_quoted = + if is_batch_file program then + let command = Filename.quote_command program args in + ( resolve_program ~cwd "cmd.exe", + ["/D"; "/V:OFF"; "/S"; "/C"; command], + true ) + else (program, args, false) + in + let command_line = + serialize_command_line ~shell_command_is_quoted ~program ~args + in + let program = program_for_working_directory ~cwd program in + (* Starting suspended closes the only interval in which a child could create + descendants before the job owns its process tree. *) + spawn_owned ~env ~cwd ~program ~command_line ~stdin ~stdout ~stderr + +let null_device = "NUL" + +external current_process_id : unit -> int = "rewatch_windows_current_process_id" + +let process_id process = process.wait_id +let release_process process = close_process_job process.job + +let create_capture_pipes = Platform_common.create_capture_pipes + +let signal_process_tree ~root_reaped:_ process _signal = + if terminate_process_job process.job then Ok () + else Error "TerminateJobObject failed" + +let termination_signal_mutex = Mutex.create () + +let defer_termination_signals () = + (* Windows has no per-thread signal mask, so temporary handlers affect every + domain. Serializing installation keeps concurrently published artifacts + from restoring one another's handlers out of order. *) + Mutex.lock termination_signal_mutex; + let pending = ref [] in + let defer signal = + if not (List.mem signal !pending) then pending := signal :: !pending + in + let previous_int = + try Sys.signal Sys.sigint (Sys.Signal_handle defer) + with exn -> + Mutex.unlock termination_signal_mutex; + raise exn + in + let previous_term = + try Sys.signal Sys.sigterm (Sys.Signal_handle defer) + with exn -> + let exn = + try + ignore (Sys.signal Sys.sigint previous_int); + exn + with restore_exn -> restore_exn + in + Mutex.unlock termination_signal_mutex; + raise exn + in + let restored = ref false in + let dispatch signal behavior = + match behavior with + | Sys.Signal_ignore -> () + | Sys.Signal_handle handler -> handler signal + | Sys.Signal_default -> raise Sys.Break + in + fun () -> + if not !restored then ( + restored := true; + let restore_error = ref None in + let restore signal behavior = + try ignore (Sys.signal signal behavior) + with exn -> + if Option.is_none !restore_error then restore_error := Some exn + in + restore Sys.sigint previous_int; + restore Sys.sigterm previous_term; + Mutex.unlock termination_signal_mutex; + Option.iter raise !restore_error; + List.rev !pending + |> List.iter (fun signal -> + dispatch signal + (if signal = Sys.sigint then previous_int else previous_term))) + +let graceful_termination_signal = Sys.sigkill +let escalate_process_groups = false + +let parse_tasklist_csv_line line = + let length = String.length line in + let rec parse_field fields index = + if index >= length || line.[index] <> '"' then None + else + let buffer = Buffer.create 32 in + let rec parse_char index = + if index >= length then None + else + match line.[index] with + | '"' when index + 1 < length && line.[index + 1] = '"' -> + Buffer.add_char buffer '"'; + parse_char (index + 2) + | '"' -> + let fields = Buffer.contents buffer :: fields in + let next = index + 1 in + if next = length then Some (List.rev fields) + else if line.[next] = ',' then parse_field fields (next + 1) + else None + | character -> + Buffer.add_char buffer character; + parse_char (index + 1) + in + parse_char (index + 1) + in + if length = 0 then None else parse_field [] 0 + +type tasklist_probe = Process_found | Process_absent | Malformed_output + +let tasklist_probe ~pid output = + let lines = + output |> String.trim |> String.split_on_char '\n' |> List.map String.trim + |> List.filter (( <> ) "") + in + let rows = List.map parse_tasklist_csv_line lines in + let valid_row = function + | Some [_image; row_pid; _session; _session_number; _memory] -> + Option.is_some (int_of_string_opt row_pid) + | Some _ | None -> false + in + if lines = [] || not (List.for_all valid_row rows) then Malformed_output + else if + List.exists + (function + | Some [image; row_pid; _session; _session_number; _memory] -> + String.starts_with ~prefix:"rescript" (String.lowercase_ascii image) + && row_pid = string_of_int pid + | Some _ | None -> false) + rows + then Process_found + else Process_absent + +let probe_process ~run pid = + let tasklist = + match Sys.getenv_opt "SystemRoot" with + | Some root -> + Filename.concat (Filename.concat root "System32") "tasklist.exe" + | None -> "tasklist.exe" + in + match run tasklist ["/FO"; "CSV"; "/NH"] with + | Some (Unix.WEXITED 0, stdout) -> ( + match tasklist_probe ~pid stdout with + | Process_found | Malformed_output -> true + | Process_absent -> false) + | Some _ | None -> true + +let process_is_active ~run value = + Platform_common.process_is_active ~probe:(probe_process ~run) value diff --git a/rewatch-ocaml/process.ml b/rewatch-ocaml/process.ml new file mode 100644 index 00000000000..81a883768eb --- /dev/null +++ b/rewatch-ocaml/process.ml @@ -0,0 +1,549 @@ +module Child = Process_child + +type result = Child.result = { + status: Unix.process_status; + stdout: string; + stderr: string; +} + +type job = Child.job = {program: string; args: string list; cwd: string} +type task = {job: job; env: Spawn.Env.t option; on_result: result -> result} + +exception Error = Child.Error +exception Interrupted of int + +let decode_utf8_lossy = Child.decode_utf8_lossy +let succeeded = Child.succeeded +let status_string = Child.status_string + +(* Reader threads are needed because a child can block when either output pipe + fills, and Windows cannot use select to drain anonymous pipes concurrently. + The threads perform only blocking I/O; dependency scheduling stays on the + calling thread. *) +let default_max_jobs = min 32 (max 1 (Domain.recommended_domain_count ())) + +let task ?env ?(on_result = fun result -> result) job = {job; env; on_result} + +let run_parallel_map_with_notifier ~max_jobs ~poll ~on_complete notifier values + ~job = + let indexed = List.mapi (fun index value -> (index, value)) values in + let results = Array.make (List.length values) None in + let active = ref [] in + let launch_indexed (index, value) = + active := Child.launch ~notifier index (job value) :: !active + in + let rec fill slots queued = + if slots = 0 then queued + else + match queued with + | [] -> [] + | job :: rest -> + launch_indexed job; + fill (slots - 1) rest + in + let rec schedule queued = + let queued = fill (max_jobs - List.length !active) queued in + match !active with + | [] -> () + | _ -> + let (child, result), deferred_signals = + Child.wait_for_running ~poll notifier !active + in + Signal_restore.protect deferred_signals (fun () -> + active := List.filter (fun running -> running != child) !active; + Child.release_running child; + results.(Child.payload child) <- Some result; + on_complete (Child.payload child)); + schedule queued + in + try + schedule indexed; + Array.to_list results + |> List.map (function + | Some result -> result + | None -> raise (Error "subprocess result was not collected")) + with exn -> + Child.terminate_running !active; + raise exn + +let run_parallel_map ?(max_jobs = default_max_jobs) ?poll + ?(on_complete = fun _ -> ()) values ~job = + if max_jobs < 1 then raise (Error "max_jobs must be at least one"); + match values with + | [] -> [] + | _ -> + let poll, ticker_enabled = + match poll with + | Some poll -> (poll, true) + | None -> ((fun () -> ()), false) + in + Child.with_completion_notifier ~ticker_enabled (fun notifier -> + run_parallel_map_with_notifier ~max_jobs ~poll ~on_complete notifier + values ~job) + +let run_parallel ?max_jobs ?poll ?on_complete jobs = + run_parallel_map ?max_jobs ?poll ?on_complete jobs ~job:Fun.id + +type 'a work = {key: string; dependencies: string list; value: 'a} +type failure_action = + | Abort_immediately + | Stop_new_work + | Continue_independent_work + +module Work_ready = Set.Make (struct + type t = int * string + + let compare (first_priority, first_key) (second_priority, second_key) = + let by_priority = compare second_priority first_priority in + if by_priority <> 0 then by_priority + else String.compare first_key second_key +end) + +type cancellation_state = + | Cancellation_not_requested + | Cancellation_requested + | Cancellation_failed of exn + +type termination_state = + | Termination_confirmed + | Termination_unconfirmed of exn + +type pool_active = { + id: int; + child: unit Child.running; + mutable cancellation: cancellation_state; +} + +let cancellation_was_requested = function + | Cancellation_not_requested -> false + | Cancellation_requested | Cancellation_failed _ -> true + +type 'a pool_completion = + | Task_completed of 'a * result + | Task_failed of 'a * exn + +type 'a worker_pool = { + notifier: Child.completion_notifier; + mutex: Mutex.t; + work_available: Condition.t; + cancellation_finished: Condition.t; + queued: ('a * task) Queue.t; + completed_tasks: 'a pool_completion Queue.t; + mutable active_children: pool_active list; + mutable next_active_id: int; + mutable stopping: bool; + mutable signalling_cancellation: bool; + mutable workers: unit Domain.t list; +} + +let launch_worker_task notifier task = + (* Signal handlers are process-wide and may execute on a worker domain. A + worker therefore keeps ownership across launch without temporarily + replacing those handlers: an interruption unwinds through [launch] or the + completion queue, and the scheduler then cancels the other process trees. *) + Child.launch ?env:task.env ~defer_signals:false ~notifier () task.job + +let remove_active pool active = + Child.with_lock pool.mutex (fun () -> + while + pool.signalling_cancellation + && cancellation_was_requested active.cancellation + do + Condition.wait pool.cancellation_finished pool.mutex + done; + pool.active_children <- + List.filter + (fun current -> current.id <> active.id) + pool.active_children) + +let release_active active = Child.release active.child + +let release_active_after_completion active = + Child.release_after_completion active.child + +let complete_pool_task pool completion = + Child.with_lock pool.mutex (fun () -> + Queue.add completion pool.completed_tasks); + Child.notify_completion pool.notifier + +let run_pool_task pool payload task = + match + try Ok (launch_worker_task pool.notifier task) with exn -> Error exn + with + | Error exn -> complete_pool_task pool (Task_failed (payload, exn)) + | Ok child -> + let active, cancel_after_launch = + Child.with_lock pool.mutex (fun () -> + let active = + { + id = pool.next_active_id; + child; + cancellation = + (if pool.stopping then Cancellation_requested + else Cancellation_not_requested); + } + in + pool.next_active_id <- pool.next_active_id + 1; + pool.active_children <- active :: pool.active_children; + (active, cancellation_was_requested active.cancellation)) + in + let wait_result = + try + if cancel_after_launch then Child.signal_running [child]; + Ok + (Child.wait_for_running ~defer_signals:false + ~poll:(fun () -> + match + Child.with_lock pool.mutex (fun () -> active.cancellation) + with + | Cancellation_failed exn -> raise exn + | Cancellation_not_requested | Cancellation_requested -> ()) + pool.notifier [child]) + with exn -> Error exn + in + let completion = + match wait_result with + | Ok ((_, result), deferred_signals) -> ( + remove_active pool active; + try + Signal_restore.protect deferred_signals (fun () -> + Child.await_termination child; + release_active active; + Task_completed (payload, task.on_result result)) + with exn -> Task_failed (payload, exn)) + | Error exn -> ( + let termination = + try + Child.signal_running [child]; + Termination_confirmed + with cancellation_exn -> Termination_unconfirmed cancellation_exn + in + match termination with + | Termination_confirmed -> ( + remove_active pool active; + try + Child.await_termination child; + release_active active; + Task_failed (payload, exn) + with release_exn -> Task_failed (payload, release_exn)) + | Termination_unconfirmed cancellation_exn -> + remove_active pool active; + release_active_after_completion active; + Task_failed (payload, cancellation_exn)) + in + complete_pool_task pool completion + +let rec worker_loop pool = + let queued = + Child.with_lock pool.mutex (fun () -> + while Queue.is_empty pool.queued && not pool.stopping do + Condition.wait pool.work_available pool.mutex + done; + if Queue.is_empty pool.queued then None + else Some (Queue.take pool.queued)) + in + match queued with + | None -> () + | Some (payload, task) -> + run_pool_task pool payload task; + worker_loop pool + +let create_worker_pool ~max_jobs notifier = + let pool = + { + notifier; + mutex = Mutex.create (); + work_available = Condition.create (); + cancellation_finished = Condition.create (); + queued = Queue.create (); + completed_tasks = Queue.create (); + active_children = []; + next_active_id = 0; + stopping = false; + signalling_cancellation = false; + workers = []; + } + in + let rec start_workers remaining = + if remaining > 0 then ( + let worker = Domain.spawn (fun () -> worker_loop pool) in + pool.workers <- worker :: pool.workers; + start_workers (remaining - 1)) + in + try + start_workers max_jobs; + pool + with exn -> + Child.with_lock pool.mutex (fun () -> + pool.stopping <- true; + Condition.broadcast pool.work_available); + List.iter Domain.join pool.workers; + raise exn + +let submit_pool_task pool payload task = + Child.with_lock pool.mutex (fun () -> + if pool.stopping then raise (Error "subprocess worker pool is stopping"); + Queue.add (payload, task) pool.queued; + Condition.signal pool.work_available) + +let await_pool_completion ~poll pool = + let rec wait generation = + match + Child.with_lock pool.mutex (fun () -> + if Queue.is_empty pool.completed_tasks then None + else Some (Queue.take pool.completed_tasks)) + with + | Some completion -> completion + | None -> + poll (); + Child.await_notification pool.notifier generation |> wait + in + Child.notifier_generation pool.notifier |> wait + +let stop_worker_pool ~cancel pool = + let active = + Child.with_lock pool.mutex (fun () -> + pool.stopping <- true; + Queue.clear pool.queued; + Condition.broadcast pool.work_available; + if cancel then ( + pool.signalling_cancellation <- true; + pool.active_children + |> List.filter_map (fun active -> + match active.cancellation with + | Cancellation_requested | Cancellation_failed _ -> None + | Cancellation_not_requested -> + active.cancellation <- Cancellation_requested; + Some active.child)) + else []) + in + let signal_error = + try + Child.signal_running active; + None + with exn -> Some exn + in + Child.with_lock pool.mutex (fun () -> + Option.iter + (fun exn -> + List.iter + (fun active -> + match active.cancellation with + | Cancellation_requested -> + active.cancellation <- Cancellation_failed exn + | Cancellation_not_requested | Cancellation_failed _ -> ()) + pool.active_children) + signal_error; + pool.signalling_cancellation <- false; + Condition.broadcast pool.cancellation_finished); + Option.iter (fun _ -> Child.notify_completion pool.notifier) signal_error; + List.iter Domain.join pool.workers; + Option.iter raise signal_error + +let with_worker_pool ~max_jobs notifier action = + let pool = create_worker_pool ~max_jobs notifier in + match action pool with + | result -> + stop_worker_pool ~cancel:false pool; + result + | exception exn -> + let exn = + try + stop_worker_pool ~cancel:true pool; + exn + with cancellation_exn -> cancellation_exn + in + raise exn + +let run_dependency_graph_with_notifier ~max_jobs ~on_failure ~poll notifier + works ~next = + let graph = + Graph.create_index works + ~name:(fun work -> work.key) + ~deps:(fun work -> work.dependencies) + ~validation: + (Graph.Reject_invalid + { + duplicate_node = + (fun key -> Error ("duplicate subprocess work key: " ^ key)); + unknown_dependency = + (fun ~node ~dependency -> + Error + (Printf.sprintf + "unknown dependency %s for subprocess work %s" dependency + node)); + }) + in + let count = Graph.node_count graph in + let pending = Hashtbl.create count in + List.iter + (fun work -> + Hashtbl.add pending work.key (Graph.dependency_count graph work.key)) + works; + let priorities = Hashtbl.create count in + let remaining_dependents = Hashtbl.create count in + let leaves = Queue.create () in + List.iter + (fun work -> + let dependent_count = List.length (Graph.dependents graph work.key) in + Hashtbl.add remaining_dependents work.key dependent_count; + if dependent_count = 0 then ( + Hashtbl.add priorities work.key 1; + Queue.add work.key leaves)) + works; + let prioritized = ref 0 in + while not (Queue.is_empty leaves) do + let key = Queue.take leaves in + incr prioritized; + let key_priority = Hashtbl.find priorities key in + Graph.dependencies graph key + |> List.iter (fun dependency -> + let candidate = key_priority + 1 in + let current = + Hashtbl.find_opt priorities dependency |> Option.value ~default:1 + in + if candidate > current then + Hashtbl.replace priorities dependency candidate; + let remaining = Hashtbl.find remaining_dependents dependency - 1 in + Hashtbl.replace remaining_dependents dependency remaining; + if remaining = 0 then Queue.add dependency leaves) + done; + let () = + if !prioritized <> count then + let cycle = + Graph.shortest_cycle_in_index graph |> Option.value ~default:[] + in + let details = + match cycle with + | [] -> "" + | cycle -> ": " ^ String.concat " -> " cycle + in + raise (Error ("subprocess dependency graph contains a cycle" ^ details)) + in + let ready = ref Work_ready.empty in + let add_ready work = + ready := Work_ready.add (Hashtbl.find priorities work.key, work.key) !ready + in + List.iter + (fun work -> if Hashtbl.find pending work.key = 0 then add_ready work) + works; + let in_flight = ref 0 in + let completed = ref 0 in + let stopped = ref false in + let errors = ref [] in + let record_error work exn = + match on_failure exn with + | Abort_immediately -> raise exn + | Stop_new_work -> + stopped := true; + errors := (work.key, exn) :: !errors + | Continue_independent_work -> errors := (work.key, exn) :: !errors + in + let complete work = + incr completed; + Graph.dependents graph work.key + |> List.iter (fun dependent_key -> + let remaining = Hashtbl.find pending dependent_key - 1 in + Hashtbl.replace pending dependent_key remaining; + if remaining = 0 then add_ready (Graph.find_node graph dependent_key)) + in + let rec fill pool = + if (not !stopped) && !in_flight < max_jobs then + match Work_ready.min_elt_opt !ready with + | None -> () + | Some ((_, key) as ready_key) -> + ready := Work_ready.remove ready_key !ready; + let work = Graph.find_node graph key in + (try + match next work.value None with + | None -> complete work + | Some task -> + submit_pool_task pool work task; + incr in_flight + with exn -> record_error work exn); + fill pool + in + let rec schedule pool = + fill pool; + if !in_flight = 0 then + match + !errors + |> List.sort (fun (first, _) (second, _) -> String.compare first second) + with + | (_, exn) :: _ -> raise exn + | [] when !completed <> count -> + raise (Error "subprocess dependency graph stalled") + | [] -> () + else + let completion = await_pool_completion ~poll pool in + decr in_flight; + (match completion with + | Task_failed (work, exn) -> record_error work exn + | Task_completed (work, result) -> ( + try + match next work.value (Some result) with + | Some task -> + submit_pool_task pool work task; + incr in_flight + | None -> complete work + with exn -> record_error work exn)); + schedule pool + in + with_worker_pool ~max_jobs:(min max_jobs count) notifier schedule + +let run_dependency_graph ?(max_jobs = default_max_jobs) + ?(on_failure = + function + | Sys.Break | Interrupted _ -> Abort_immediately + | _ -> Stop_new_work) ?poll works ~next = + if max_jobs < 1 then raise (Error "max_jobs must be at least one"); + match works with + | [] -> () + | _ -> + let poll, ticker_enabled = + match poll with + | Some poll -> (poll, true) + | None -> ((fun () -> ()), false) + in + Child.with_completion_notifier ~ticker_enabled (fun notifier -> + run_dependency_graph_with_notifier ~max_jobs ~on_failure ~poll notifier + works ~next) + +let run_one ?poll ?stdout_chunk ?stderr_chunk ?stdin ~cwd program args = + let poll, ticker_enabled = + match poll with + | Some poll -> (poll, true) + | None -> ((fun () -> ()), false) + in + Child.with_completion_notifier ~ticker_enabled (fun notifier -> + let child = + Child.launch ?stdout_chunk ?stderr_chunk ?stdin ~notifier () + {program; args; cwd} + in + let completion_received = ref false in + try + let (_, result), deferred_signals = + Child.wait_for_running ~poll notifier [child] + in + completion_received := true; + Signal_restore.protect deferred_signals (fun () -> + Child.release_running child; + result) + with exn -> + if not !completion_received then Child.terminate_running [child]; + raise exn) + +let run ?poll ~cwd program args = run_one ?poll ~cwd program args + +let run_streaming ?poll ~cwd program args = + let write channel bytes count = + output channel bytes 0 count; + flush channel + in + let stdin = + if Platform.inherit_streaming_terminal_stdin || not (Unix.isatty Unix.stdin) + then Child.Inherit_stdin + else Child.Null_stdin + in + run_one ?poll ~stdout_chunk:(write stdout) ~stderr_chunk:(write stderr) ~stdin + ~cwd program args diff --git a/rewatch-ocaml/process.mli b/rewatch-ocaml/process.mli new file mode 100644 index 00000000000..efaf3b79ffe --- /dev/null +++ b/rewatch-ocaml/process.mli @@ -0,0 +1,57 @@ +(** Process scheduling has two policies but one child-lifecycle owner. Parallel + lists stop immediately on interruption; dependency graphs can stop admitting + new work while already-started children drain. {!Process_child} owns the + operating-system resources in both cases. *) + +type result = {status: Unix.process_status; stdout: string; stderr: string} +type job = {program: string; args: string list; cwd: string} +type task + +exception Error of string +exception Interrupted of int + +val decode_utf8_lossy : string -> string +val succeeded : result -> bool +val status_string : Unix.process_status -> string +val default_max_jobs : int + +val task : ?env:Spawn.Env.t -> ?on_result:(result -> result) -> job -> task + +val run_parallel : + ?max_jobs:int -> + ?poll:(unit -> unit) -> + ?on_complete:(int -> unit) -> + job list -> + result list + +val run_parallel_map : + ?max_jobs:int -> + ?poll:(unit -> unit) -> + ?on_complete:(int -> unit) -> + 'a list -> + job:('a -> job) -> + result list + +type 'a work = {key: string; dependencies: string list; value: 'a} + +(* [Stop_new_work] preserves results from children that already started while + ensuring no newly ready dependency is launched after a build failure. + [Continue_independent_work] also drains work that was already ready, while + failed prerequisites continue to block their dependents. *) +type failure_action = + | Abort_immediately + | Stop_new_work + | Continue_independent_work + +val run_dependency_graph : + ?max_jobs:int -> + ?on_failure:(exn -> failure_action) -> + ?poll:(unit -> unit) -> + 'a work list -> + next:('a -> result option -> task option) -> + unit + +val run : ?poll:(unit -> unit) -> cwd:string -> string -> string list -> result + +val run_streaming : + ?poll:(unit -> unit) -> cwd:string -> string -> string list -> result diff --git a/rewatch-ocaml/process_child.ml b/rewatch-ocaml/process_child.ml new file mode 100644 index 00000000000..99341f134b3 --- /dev/null +++ b/rewatch-ocaml/process_child.ml @@ -0,0 +1,452 @@ +type result = {status: Unix.process_status; stdout: string; stderr: string} +type job = {program: string; args: string list; cwd: string} +type stdin_policy = Inherit_stdin | Null_stdin + +exception Error of string + +let decode_utf8_lossy value = + if String.is_valid_utf_8 value then value + else + let output = Buffer.create (String.length value) in + let rec loop index = + if index < String.length value then ( + let decoded = String.get_utf_8_uchar value index in + let length = max 1 (Uchar.utf_decode_length decoded) in + if Uchar.utf_decode_is_valid decoded then + Buffer.add_substring output value index length + else Buffer.add_utf_8_uchar output Uchar.rep; + loop (index + length)) + in + loop 0; + Buffer.contents output + +let succeeded result = result.status = Unix.WEXITED 0 + +let status_string = function + | Unix.WEXITED code -> Printf.sprintf "exit code %d" code + | Unix.WSIGNALED signal -> Printf.sprintf "signal %d" signal + | Unix.WSTOPPED signal -> Printf.sprintf "stopped by signal %d" signal + +type capture = { + thread: Thread.t; + outcome: (string, exn) Stdlib.result option ref; +} + +type child_wait = { + thread: Thread.t; + direct_outcome: (Unix.process_status, exn) Stdlib.result option Atomic.t; + outcome: (result, exn) Stdlib.result option Atomic.t; +} + +type 'a running = { + payload: 'a; + process: Platform.process; + pid: int; + child_wait: child_wait; +} + +type launch_ownership = { + mutable stdin: Unix.file_descr option; + mutable stdout_read: Unix.file_descr option; + mutable stdout_write: Unix.file_descr option; + mutable stderr_read: Unix.file_descr option; + mutable stderr_write: Unix.file_descr option; + mutable stdout_capture: capture option; + mutable stderr_capture: capture option; + mutable process: Platform.process option; + mutable child_wait: child_wait option; + mutable termination_error: string option; +} + +let empty_launch_ownership () = + { + stdin = None; + stdout_read = None; + stdout_write = None; + stderr_read = None; + stderr_write = None; + stdout_capture = None; + stderr_capture = None; + process = None; + child_wait = None; + termination_error = None; + } + +type completion_notifier = { + mutex: Mutex.t; + condition: Condition.t; + mutable generation: int; + mutable stopped: bool; +} + +let with_lock mutex action = + Mutex.lock mutex; + (* Unlocking in the exception path prevents one failed callback from + permanently blocking every waiter that shares this notifier. *) + Fun.protect ~finally:(fun () -> Mutex.unlock mutex) action + +let create_completion_notifier () = + { + mutex = Mutex.create (); + condition = Condition.create (); + generation = 0; + stopped = false; + } + +let notify_completion notifier = + with_lock notifier.mutex (fun () -> + if not notifier.stopped then ( + notifier.generation <- notifier.generation + 1; + Condition.broadcast notifier.condition)) + +let notifier_generation notifier = + with_lock notifier.mutex (fun () -> notifier.generation) + +let await_notification notifier generation = + with_lock notifier.mutex (fun () -> + while notifier.generation = generation && not notifier.stopped do + Condition.wait notifier.condition notifier.mutex + done; + notifier.generation) + +let with_completion_notifier ~ticker_enabled action = + (* The scheduler needs immediate child completion without repeatedly asking + the operating system about every running PID. A condition variable wakes + it when status and captured output are both ready; one ticker also wakes a + supplied watch poll callback every five milliseconds while children are + busy. *) + let notifier = create_completion_notifier () in + let rec send_tick () = + Thread.delay 0.005; + let continue = + with_lock notifier.mutex (fun () -> + if notifier.stopped then false + else ( + notifier.generation <- notifier.generation + 1; + Condition.broadcast notifier.condition; + true)) + in + if continue then send_tick () + in + let deferred_signals = Signal_restore.create ~defer:true in + let ticker = ref None in + let stopped = ref false in + let stop () = + if not !stopped then ( + with_lock notifier.mutex (fun () -> + notifier.stopped <- true; + Condition.broadcast notifier.condition); + Option.iter Thread.join !ticker; + stopped := true) + in + try + if ticker_enabled then ticker := Some (Thread.create send_tick ()); + Fun.protect ~finally:stop (fun () -> + Signal_restore.restore deferred_signals; + action notifier) + with exn -> + stop (); + raise (Signal_restore.exception_after_restore deferred_signals exn) + +let close_noerr descriptor = + try Unix.close descriptor with Unix.Unix_error _ -> () + +let start_capture ?on_chunk descriptor : capture = + let outcome = ref None in + let thread = + Thread.create + (fun () -> + outcome := + Some + (try + Fun.protect + ~finally:(fun () -> close_noerr descriptor) + (fun () -> + let output = Buffer.create 4096 in + let bytes = Bytes.create 65536 in + let rec read () = + try + match + Unix.read descriptor bytes 0 (Bytes.length bytes) + with + | 0 -> () + | count -> + (match on_chunk with + | Some on_chunk -> on_chunk bytes count + | None -> Buffer.add_subbytes output bytes 0 count); + read () + with Unix.Unix_error (Unix.EINTR, _, _) -> read () + in + read (); + Ok + (match on_chunk with + | Some _ -> "" + | None -> Buffer.contents output |> decode_utf8_lossy)) + with exn -> Error exn)) + () + in + {thread; outcome} + +let capture_outcome (capture : capture) = + Thread.join capture.thread; + match !(capture.outcome) with + | Some outcome -> outcome + | None -> Error (Failure "subprocess output reader did not finish") + +let capture_error exn = + Error ("failed to capture subprocess output: " ^ Printexc.to_string exn) + +let start_child_wait pid notifier stdout_capture stderr_capture : child_wait = + let direct_outcome = Atomic.make None in + let outcome = Atomic.make None in + let rec wait () = + try + let _, status = Unix.waitpid [] pid in + status + with Unix.Unix_error (Unix.EINTR, _, _) -> wait () + in + let thread = + Thread.create + (fun () -> + let status = try Ok (wait ()) with exn -> Error exn in + Atomic.set direct_outcome (Some status); + let stdout = capture_outcome stdout_capture in + let stderr = capture_outcome stderr_capture in + let result = + match (status, stdout, stderr) with + | Ok status, Ok stdout, Ok stderr -> Ok {status; stdout; stderr} + | Error exn, _, _ -> Error exn + | _, Error exn, _ | _, _, Error exn -> Error (capture_error exn) + in + Atomic.set outcome (Some result); + notify_completion notifier) + () + in + {thread; direct_outcome; outcome} + +let fail_launch ownership deferred_signals launch_error = + Option.iter + (fun process -> + let pid = Platform.process_id process in + let root_reaped = + match ownership.child_wait with + | Some wait -> Option.is_some (Atomic.get wait.direct_outcome) + | None -> false + in + (match Platform.signal_process_tree ~root_reaped process Sys.sigkill with + | Ok () -> () + | Error message -> ownership.termination_error <- Some message); + if + Option.is_none ownership.termination_error + && Option.is_none ownership.child_wait + then try ignore (Unix.waitpid [] pid) with Unix.Unix_error _ -> ()) + ownership.process; + List.iter + (fun descriptor -> Option.iter close_noerr descriptor) + [ + ownership.stdout_write; + ownership.stderr_write; + ownership.stdout_read; + ownership.stderr_read; + ownership.stdin; + ]; + (match ownership.child_wait with + | Some wait when Option.is_none ownership.termination_error -> + Thread.join wait.thread + | Some _ -> () + | None + when Option.is_none ownership.termination_error + && Option.is_some ownership.process -> + Option.iter + (fun (capture : capture) -> Thread.join capture.thread) + ownership.stdout_capture; + Option.iter + (fun (capture : capture) -> Thread.join capture.thread) + ownership.stderr_capture + | None -> ()); + let release_error = + try + Option.iter Platform.release_process ownership.process; + None + with release_exn -> Some release_exn + in + let restore_error = + try + Signal_restore.restore deferred_signals; + None + with signal_exn -> Some signal_exn + in + let error = + match ownership.termination_error with + | Some message -> + Error + ("Could not terminate a partially launched subprocess tree: " ^ message) + | None -> ( + match (restore_error, release_error) with + | Some signal_exn, _ -> signal_exn + | None, Some release_exn -> release_exn + | None, None -> launch_error) + in + raise error + +let launch ?env ?stdout_chunk ?stderr_chunk ?(stdin = Null_stdin) + ?(defer_signals = true) ~notifier payload job = + (* Capture descriptors need a cleanup owner before asynchronous watch + termination can raise. Signals are therefore deferred across pipe + acquisition and restored only after every descriptor has an owner. *) + let deferred_signals = Signal_restore.create ~defer:defer_signals in + let ownership = empty_launch_ownership () in + try + let pipes = Platform.create_capture_pipes () in + let (stdout_read, stdout_write), (stderr_read, stderr_write) = pipes in + ownership.stdout_read <- Some stdout_read; + ownership.stdout_write <- Some stdout_write; + ownership.stderr_read <- Some stderr_read; + ownership.stderr_write <- Some stderr_write; + let stdout = start_capture ?on_chunk:stdout_chunk stdout_read in + ownership.stdout_read <- None; + ownership.stdout_capture <- Some stdout; + let stderr = start_capture ?on_chunk:stderr_chunk stderr_read in + ownership.stderr_read <- None; + ownership.stderr_capture <- Some stderr; + let stdin = + match stdin with + | Inherit_stdin -> Unix.stdin + | Null_stdin -> + let descriptor = + Unix.openfile Platform.null_device [Unix.O_RDONLY; Unix.O_CLOEXEC] 0 + in + ownership.stdin <- Some descriptor; + descriptor + in + let process = + Platform.spawn ~env ~cwd:job.cwd ~program:job.program ~args:job.args + ~stdin ~stdout:stdout_write ~stderr:stderr_write + in + ownership.process <- Some process; + let pid = Platform.process_id process in + Option.iter close_noerr ownership.stdin; + ownership.stdin <- None; + ownership.stdout_write <- None; + close_noerr stdout_write; + ownership.stderr_write <- None; + close_noerr stderr_write; + let wait = start_child_wait pid notifier stdout stderr in + ownership.child_wait <- Some wait; + Signal_restore.restore deferred_signals; + {payload; process; pid; child_wait = wait} + with exn -> fail_launch ownership deferred_signals exn + +let wait_for_running ~poll ?(defer_signals = true) notifier active = + let rec find_completed = function + | [] -> None + | (child : _ running) :: rest -> ( + match Atomic.get child.child_wait.outcome with + | Some outcome -> Some (child, outcome) + | None -> find_completed rest) + in + let rec wait generation = + match find_completed active with + | Some (child, Ok result) -> + let deferred_signals = Signal_restore.create ~defer:defer_signals in + ((child, result), deferred_signals) + | Some (_, Error exn) -> raise exn + | None -> + poll (); + await_notification notifier generation |> wait + in + notifier_generation notifier |> wait + +let signal_running (children : _ running list) = + if children <> [] then ( + let completion_ready (child : _ running) = + Option.is_some (Atomic.get child.child_wait.outcome) + in + let root_identity_lost (child : _ running) = + Option.is_some (Atomic.get child.child_wait.direct_outcome) + in + let signal_group signal (child : _ running) = + let root_reaped = root_identity_lost child in + Platform.signal_process_tree ~root_reaped child.process signal + in + let signal_all signal = + List.fold_left + (fun errors child -> + if completion_ready child then errors + else + match signal_group signal child with + | Ok () -> errors + | Error message -> (child, message) :: errors) + [] children + in + let graceful_signal = Platform.graceful_termination_signal in + let graceful_errors = signal_all graceful_signal in + let deadline = Unix.gettimeofday () +. 0.25 in + let rec wait_until_deadline children = + let remaining = + List.filter (fun child -> not (root_identity_lost child)) children + in + if remaining <> [] && Unix.gettimeofday () < deadline then ( + ignore (Unix.select [] [] [] 0.01); + wait_until_deadline remaining) + else remaining + in + ignore (wait_until_deadline children); + (* Every original process group needs escalation because a direct child can + exit while a PPX or helper in its group remains alive. *) + let termination_errors = + if Platform.escalate_process_groups then signal_all Sys.sigkill + else graceful_errors + in + (* On Darwin, signalling a group containing only zombies returns EPERM. + Pipe closure and waiter publication happen on separate threads, so the + process result can become ready just after that harmless failure. A + bounded wait distinguishes this race from a live tree that the platform + genuinely could not terminate. *) + let completion_deadline = Unix.gettimeofday () +. 0.25 in + let rec unresolved errors = + let errors = + List.filter (fun (child, _) -> not (completion_ready child)) errors + in + if errors <> [] && Unix.gettimeofday () < completion_deadline then ( + ignore (Unix.select [] [] [] 0.01); + unresolved errors) + else errors + in + match unresolved termination_errors |> List.rev with + | [] -> () + | errors -> + raise + (Error + ("Could not terminate a subprocess tree: " + ^ String.concat "; " (List.map snd errors)))) + +let release_after_completion (child : _ running) = + ignore + (Thread.create + (fun () -> + Thread.join child.child_wait.thread; + Platform.release_process child.process) + ()) + +let payload (child : _ running) = child.payload +let pid (child : _ running) = child.pid +let await_termination (child : _ running) = Thread.join child.child_wait.thread +let release (child : _ running) = Platform.release_process child.process + +let terminate_running (children : _ running list) = + if children <> [] then ( + try + signal_running children; + List.iter + (fun (child : _ running) -> + Thread.join child.child_wait.thread; + Platform.release_process child.process) + children + with exn -> + List.iter release_after_completion children; + raise exn) + +let release_running (child : _ running) = + await_termination child; + release child diff --git a/rewatch-ocaml/process_child.mli b/rewatch-ocaml/process_child.mli new file mode 100644 index 00000000000..cf824b43c76 --- /dev/null +++ b/rewatch-ocaml/process_child.mli @@ -0,0 +1,52 @@ +(** A running child is abstract so descriptors, reader threads, the waiter, and + the platform process handle always have one cleanup owner. Callers may wait, + cancel, or release it, but cannot reconstruct a partially owned child. *) + +type result = {status: Unix.process_status; stdout: string; stderr: string} +type job = {program: string; args: string list; cwd: string} +type stdin_policy = Inherit_stdin | Null_stdin + +exception Error of string + +val decode_utf8_lossy : string -> string +val succeeded : result -> bool +val status_string : Unix.process_status -> string + +type completion_notifier +type 'a running + +val with_lock : Mutex.t -> (unit -> 'a) -> 'a + +val with_completion_notifier : + ticker_enabled:bool -> (completion_notifier -> 'a) -> 'a + +val notify_completion : completion_notifier -> unit +val notifier_generation : completion_notifier -> int +val await_notification : completion_notifier -> int -> int + +val launch : + ?env:Spawn.Env.t -> + ?stdout_chunk:(bytes -> int -> unit) -> + ?stderr_chunk:(bytes -> int -> unit) -> + ?stdin:stdin_policy -> + ?defer_signals:bool -> + notifier:completion_notifier -> + 'a -> + job -> + 'a running + +val wait_for_running : + poll:(unit -> unit) -> + ?defer_signals:bool -> + completion_notifier -> + 'a running list -> + ('a running * result) * Signal_restore.t + +val payload : 'a running -> 'a +val pid : 'a running -> int +val signal_running : 'a running list -> unit +val await_termination : 'a running -> unit +val release : 'a running -> unit +val release_after_completion : 'a running -> unit +val terminate_running : 'a running list -> unit +val release_running : 'a running -> unit diff --git a/rewatch-ocaml/project_context.ml b/rewatch-ocaml/project_context.ml new file mode 100644 index 00000000000..a72fc4745ba --- /dev/null +++ b/rewatch-ocaml/project_context.ml @@ -0,0 +1,189 @@ +exception Error of string +exception Package_error of string + +let canonical_project_root folder = + if not (File_util.exists folder) then + raise + (Error + ("Could not start Rescript build: Could not write lockfile because \ + the specified project folder does not exist: " ^ folder)); + Platform.canonicalize_path folder + +type dependency_context = { + current_root: string; + workspace_root: string; + allow_upward_search: bool; + include_workspace_dependencies: bool; +} + +let path_is_within_canonical ~root path = + let normalize = Platform.normalize_path_for_comparison in + let root = normalize root in + let path = normalize path in + path = root || String.starts_with ~prefix:(Filename.concat root "") path + +let rec nearest_config_path directory = + if Config.exists_in_root directory then Some (Config.path_in_root directory) + else + let parent = Filename.dirname directory in + if parent = directory then None else nearest_config_path parent + +(* Graph roots and resolved dependency paths are already canonical. Keeping this + predicate pure avoids repeating realpath calls throughout package traversal. *) +let is_local_dependency_canonical ~workspace path = + let equal_component left right = + Platform.normalize_path_for_comparison left + = Platform.normalize_path_for_comparison right + in + let rec contains_component path component = + if equal_component (Filename.basename path) component then true + else + let parent = Filename.dirname path in + parent <> path && contains_component parent component + in + path_is_within_canonical ~root:workspace path + && not (contains_component path "node_modules") + +let workspace_lock_root_for (current : Config.t) = + match nearest_config_path (Filename.dirname current.root) with + | Some path -> ( + match Config.load path with + | parent + when List.exists + (fun (dependency : Config.dependency) -> + dependency.name = current.name) + (parent.dependencies @ parent.dev_dependencies) -> + parent.root + | _ -> current.root) + | None -> current.root + +let workspace_lock_root folder = + workspace_lock_root_for (Config.load_root folder) + +let dependency_context (current : Config.t) = + let workspace_root = workspace_lock_root_for current in + let has_local_dependency (dependency : Config.dependency) = + let candidate = + Filename.concat + (Filename.concat current.root "node_modules") + dependency.name + in + File_util.exists candidate + && is_local_dependency_canonical ~workspace:current.root + (Platform.canonicalize_path candidate) + in + let is_monorepo_root = + List.exists has_local_dependency + (current.dependencies @ current.dev_dependencies) + in + { + current_root = current.root; + workspace_root; + allow_upward_search = workspace_root = current.root && not is_monorepo_root; + include_workspace_dependencies = workspace_root = current.root; + } + +let dependency_is_local_canonical context path = + (* A command run from one package listed by a parent workspace owns only that + package. Sibling packages still resolve through the workspace, but treating + them as local would also include their development sources, clean their + outputs, and watch their source trees. *) + context.include_workspace_dependencies + && is_local_dependency_canonical ~workspace:context.workspace_root path + +let dependency_candidates_in context package_root name = + let candidate root = + Filename.concat (Filename.concat root "node_modules") name + in + let rec in_ancestors directory acc = + let candidate = + Filename.concat (Filename.concat directory "node_modules") name + in + let parent = Filename.dirname directory in + if parent = directory then List.rev (candidate :: acc) + else in_ancestors parent (candidate :: acc) + in + let deduplicate values = + let seen = Hashtbl.create (List.length values) in + List.fold_left + (fun unique value -> + if Hashtbl.mem seen value then unique + else ( + Hashtbl.add seen value (); + value :: unique)) + [] values + |> List.rev + in + let direct = + [ + candidate package_root; + candidate context.current_root; + candidate context.workspace_root; + ] + |> deduplicate + in + if context.allow_upward_search then + deduplicate (direct @ in_ancestors (Filename.dirname package_root) []) + else direct + +let dependency_path_in context package_root name = + let existing_realpath path = + if File_util.exists path then Some (Platform.canonicalize_path path) + else None + in + dependency_candidates_in context package_root name + |> List.find_map existing_realpath + +let standalone_dependency_context root = + { + current_root = root; + workspace_root = root; + allow_upward_search = true; + include_workspace_dependencies = true; + } + +let dependency_path root name = + dependency_path_in (standalone_dependency_context root) root name + +let require_dependency_directory ~context package_root + (dependency : Config.dependency) = + match dependency_path_in context package_root dependency.name with + | None -> + raise + (Package_error + (Printf.sprintf + "Could not build package tree reading dependency '%s' at path \ + '%s'. Error: Could not resolve dependency %s" + dependency.name context.current_root dependency.name)) + | Some directory when not (Config.exists_in_root directory) -> + raise + (Package_error + (Printf.sprintf + "Could not build package tree for '%s' at path '%s'. Error: no \ + rescript.json or bsconfig.json in %s" + dependency.name context.current_root directory)) + | Some directory -> directory + +let relative_to_opt root path = + let prefix = Filename.concat root "" in + let comparable = Platform.normalize_path_for_comparison in + if comparable path = comparable root then Some "." + else if String.starts_with ~prefix:(comparable prefix) (comparable path) then + Some + (String.sub path (String.length prefix) + (String.length path - String.length prefix)) + else None + +let relative_to root path = + match relative_to_opt root path with + | Some relative -> relative + | None -> raise (Error (path ^ " is not inside " ^ root)) + +let relative_or_absolute ~root path = + Option.value (relative_to_opt root path) ~default:path + +let display_path ~root path = + match relative_or_absolute ~root path with + | "." -> "." + | relative when path_is_within_canonical ~root path -> "./" ^ relative + | absolute -> absolute diff --git a/rewatch-ocaml/project_context.mli b/rewatch-ocaml/project_context.mli new file mode 100644 index 00000000000..4e29d95f05a --- /dev/null +++ b/rewatch-ocaml/project_context.mli @@ -0,0 +1,29 @@ +exception Error of string +exception Package_error of string + +type dependency_context + +val canonical_project_root : string -> string + +val nearest_config_path : string -> string option + +val workspace_lock_root_for : Config.t -> string +val workspace_lock_root : string -> string +val dependency_context : Config.t -> dependency_context +val dependency_is_local_canonical : dependency_context -> string -> bool + +val dependency_candidates_in : + dependency_context -> string -> string -> string list + +val dependency_path_in : dependency_context -> string -> string -> string option + +val dependency_path : string -> string -> string option + +val require_dependency_directory : + context:dependency_context -> string -> Config.dependency -> string + +val relative_to : string -> string -> string +val relative_to_opt : string -> string -> string option +val relative_or_absolute : root:string -> string -> string +val display_path : root:string -> string -> string +val path_is_within_canonical : root:string -> string -> bool diff --git a/rewatch-ocaml/rescript_ocaml.ml b/rewatch-ocaml/rescript_ocaml.ml new file mode 100644 index 00000000000..9dacdeed9f2 --- /dev/null +++ b/rewatch-ocaml/rescript_ocaml.ml @@ -0,0 +1,99 @@ +(* Signal handlers can run on any domain, including between spawning a child + and registering its cleanup owner. Recording the request and raising from + the command's poll point keeps unwinding on the domain that owns scheduler, + lock, and temporary-output cleanup. *) +let with_termination_handlers action = + let requested_exit = Atomic.make 0 in + let interrupt signal = + let exit_code = + if signal = Sys.sigint then 130 + else if signal = Sys.sigterm then 143 + else 1 + in + ignore (Atomic.compare_and_set requested_exit 0 exit_code) + in + let poll () = + let exit_code = Atomic.get requested_exit in + if exit_code <> 0 then raise (Process.Interrupted exit_code) + in + let previous_sigint = Sys.signal Sys.sigint (Sys.Signal_handle interrupt) in + let result = + Fun.protect + (fun () -> + let previous_sigterm = + Sys.signal Sys.sigterm (Sys.Signal_handle interrupt) + in + Fun.protect + (fun () -> action ~poll) + ~finally:(fun () -> ignore (Sys.signal Sys.sigterm previous_sigterm))) + ~finally:(fun () -> ignore (Sys.signal Sys.sigint previous_sigint)) + in + poll (); + result + +let run_command ~poll = function + | Cli.Build + { + verbosity; + folder; + prod; + features; + warn_error; + after_build; + filter; + clear_screen; + no_timing; + } -> + ignore clear_screen; + Build.run ~poll ~verbosity ~folder ~prod ~features ~warn_error ~after_build + ~filter ~no_timing + | Cli.Watch + { + verbosity; + folder; + prod; + features; + warn_error; + after_build; + filter; + clear_screen; + no_timing; + } -> + ignore no_timing; + Build.watch ~verbosity ~folder ~prod ~features ~warn_error ~after_build + ~filter ~clear_screen + | Cli.Format (Cli.Format_stdin extension) -> + Format.format_stdin ~poll extension + | Cli.Format (Cli.Format_files {check; paths}) -> + Format.run_files ~poll ~check paths + | Cli.Compiler_args path -> print_endline (Compiler_args_command.run path) + | Cli.Clean {verbosity; folder; prod} -> + Clean.run ~poll ~verbosity ~folder ~prod + +let run = function + | Cli.Watch _ as command -> run_command ~poll:(fun () -> ()) command + | command -> + with_termination_handlers (fun ~poll -> run_command ~poll command) + +let () = + Platform.configure_standard_streams (); + try + match Cli.eval Sys.argv with + | Cli.Run command -> run command + | Cli.Exit code -> exit code + with + | Project_context.Package_error message -> + prerr_endline message; + exit 2 + | Config.Error message + | Source.Error message + | Project_context.Error message + | Process.Error message + | Format.Error message -> + prerr_endline message; + exit 1 + | Watcher.Stop -> exit 0 + | Process.Interrupted exit_code -> exit exit_code + | (Sys_error _ as exn) | (Unix.Unix_error _ as exn) -> + prerr_endline (Printexc.to_string exn); + exit 1 diff --git a/rewatch-ocaml/rewatch_version.ml b/rewatch-ocaml/rewatch_version.ml new file mode 100644 index 00000000000..f57117ff29e --- /dev/null +++ b/rewatch-ocaml/rewatch_version.ml @@ -0,0 +1 @@ +let version = "13.0.0-alpha.6" diff --git a/rewatch-ocaml/rewatch_version.mli b/rewatch-ocaml/rewatch_version.mli new file mode 100644 index 00000000000..8579193c227 --- /dev/null +++ b/rewatch-ocaml/rewatch_version.mli @@ -0,0 +1 @@ +val version : string diff --git a/rewatch-ocaml/signal_restore.ml b/rewatch-ocaml/signal_restore.ml new file mode 100644 index 00000000000..16fc46fd20d --- /dev/null +++ b/rewatch-ocaml/signal_restore.ml @@ -0,0 +1,26 @@ +type t = {restore_once: unit -> unit; mutable restored: bool} + +let create ~defer = + { + restore_once = + (if defer then Platform.defer_termination_signals () else Fun.id); + restored = false; + } + +let restore state = + if not state.restored then ( + state.restored <- true; + state.restore_once ()) + +let exception_after_restore state original = + try + restore state; + original + with restoration_error -> restoration_error + +let protect state action = + try + let result = action () in + restore state; + result + with error -> raise (exception_after_restore state error) diff --git a/rewatch-ocaml/signal_restore.mli b/rewatch-ocaml/signal_restore.mli new file mode 100644 index 00000000000..ded3c89bcaa --- /dev/null +++ b/rewatch-ocaml/signal_restore.mli @@ -0,0 +1,9 @@ +type t +(** Signal restoration is explicit because termination is deferred across + short ownership-transfer windows. [protect] restores exactly once and + preserves the original exception if restoration also fails. *) + +val create : defer:bool -> t +val restore : t -> unit +val exception_after_restore : t -> exn -> exn +val protect : t -> (unit -> 'a) -> 'a diff --git a/rewatch-ocaml/source.ml b/rewatch-ocaml/source.ml new file mode 100644 index 00000000000..08819878ddb --- /dev/null +++ b/rewatch-ocaml/source.ml @@ -0,0 +1,447 @@ +type module_ = { + name: string; + implementation: string; + interface: string option; + is_dev: bool; +} + +type discovery = { + modules: module_ list; + source_mtimes: (string * float) list; + inventory_files: string list; + present_files: string list; + gentype_dirs: string list; +} + +type discovered_file = {path: string; modified: float} +type source_kind = Implementation | Interface + +type scanned_file = {file: discovered_file; kind: source_kind; is_dev: bool} + +type scanned_sources = { + files: scanned_file list; + inventory_files: string list; + present_files: string list; + gentype_dirs: string list; +} + +exception Error of string + +let source_kind path = + match Filename.extension path with + | ".res" -> Some Implementation + | ".resi" -> Some Interface + | _ -> None + +let module_name path = + path |> Filename.basename |> Filename.remove_extension + |> String.capitalize_ascii + +let is_non_exotic_module_name name = + let is_ascii_uppercase = function + | 'A' .. 'Z' -> true + | _ -> false + in + let is_ascii_alphanumeric = function + | 'A' .. 'Z' | 'a' .. 'z' | '0' .. '9' -> true + | _ -> false + in + let rec valid_tail index = + if index = String.length name then true + else + let character = name.[index] in + (is_ascii_alphanumeric character || character = '_') + && valid_tail (index + 1) + in + String.length name > 0 && is_ascii_uppercase name.[0] && valid_tail 1 + +let display_path ~display_root root path = + let absolute = + if Filename.is_relative path then Filename.concat root path else path + in + let display_root = Platform.canonicalize_path display_root in + Project_context.relative_or_absolute ~root:display_root absolute + +let duplicate_error ~display_root root name first second = + let first, second = + let first = display_path ~display_root root first in + let second = display_path ~display_root root second in + if String.compare first second <= 0 then (first, second) else (second, first) + in + Error + (Printf.sprintf + "Could not initialize build: Duplicate module name: %s. Found in %s and \ + %s. Rename one of these files." + name first second) + +let interface_mismatch_error implementation interface = + Error + (Printf.sprintf + "Could not initialize build: Implementation and interface have \ + different path names or different cases: `%s` vs `%s`" + implementation interface) + +(* A package source tree has three consumers with deliberately different + recursion rules. Compilation follows directory links and honors source + activation/subdirs; cleanup inventories every real descendant but treats + links as leaves; GenType records every configured directory that its + subdirs setting reaches. Keeping the views in one walk gives every consumer + the same filesystem snapshot without weakening stale-output cleanup. *) +let scan_source ~root (source : Config.source) ~discover_modules ~on_missing + ~visited_dirs ~visited_inventory_dirs ~collect_inventory ~collect_gentype + ~visited_gentype_dirs candidates inventory_files present_files gentype_dirs + = + let rec scan_directory ~relative ~collect_inventory ~discover_requested + ~collect_gentype ~identity = + let absolute = Filename.concat root relative in + let coverage table requested = + if requested then + Traversal_coverage.admit table identity ~recursive:source.recurse + else Traversal_coverage.Skip + in + let discovery = coverage visited_dirs discover_requested in + let gentype = coverage visited_gentype_dirs collect_gentype in + let inventory = + if collect_inventory then + Traversal_coverage.admit visited_inventory_dirs identity ~recursive:true + else Traversal_coverage.Skip + in + let discover_here = Traversal_coverage.visits_current discovery in + let discover_children = Traversal_coverage.visits_descendants discovery in + let inventory_here = Traversal_coverage.visits_current inventory in + let inventory_children = Traversal_coverage.visits_descendants inventory in + let gentype_here = Traversal_coverage.visits_current gentype in + let gentype_children = Traversal_coverage.visits_descendants gentype in + if gentype_here then gentype_dirs := relative :: !gentype_dirs; + if + inventory <> Traversal_coverage.Skip + || discovery <> Traversal_coverage.Skip + || gentype <> Traversal_coverage.Skip + then + let entries = + try File_util.directory_entries absolute |> List.sort String.compare + with Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> + if discover_here || discover_children then on_missing absolute; + [] + in + let record_candidate relative_path absolute_path metadata = + present_files := absolute_path :: !present_files; + if inventory_here then + inventory_files := absolute_path :: !inventory_files; + if discover_here && metadata.Unix.st_kind = Unix.S_REG then + match source_kind relative_path with + | None -> () + | Some kind -> + candidates := + { + file = {path = relative_path; modified = metadata.Unix.st_mtime}; + kind; + is_dev = source.is_dev; + } + :: !candidates + in + List.iter + (fun name -> + let relative_path = Filename.concat relative name in + let absolute_path = Filename.concat root relative_path in + match Unix.lstat absolute_path with + | metadata -> ( + match metadata.Unix.st_kind with + | Unix.S_DIR -> + if inventory_children || discover_children || gentype_children + then + let identity = + Platform.directory_identity ~path:absolute_path metadata + in + scan_directory ~relative:relative_path + ~collect_inventory:inventory_children + ~discover_requested:discover_children + ~collect_gentype:gentype_children ~identity + | Unix.S_LNK -> ( + match Unix.stat absolute_path with + | target_metadata -> ( + match target_metadata.Unix.st_kind with + | Unix.S_DIR -> + if inventory_here then + inventory_files := absolute_path :: !inventory_files; + if discover_children || gentype_children then + let identity = + Platform.directory_identity ~path:absolute_path + target_metadata + in + scan_directory ~relative:relative_path + ~collect_inventory:false + ~discover_requested:discover_children + ~collect_gentype:gentype_children ~identity + | _ -> + record_candidate relative_path absolute_path target_metadata) + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) + -> + ()) + | _ -> record_candidate relative_path absolute_path metadata) + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> ()) + entries + in + let relative = source.dir in + let absolute = Filename.concat root relative in + match Unix.lstat absolute with + | metadata -> ( + match metadata.Unix.st_kind with + | Unix.S_DIR -> + let identity = Platform.directory_identity ~path:absolute metadata in + scan_directory ~relative ~collect_inventory + ~discover_requested:discover_modules ~collect_gentype ~identity + | Unix.S_LNK -> ( + match Unix.stat absolute with + | target_metadata -> ( + match target_metadata.Unix.st_kind with + | Unix.S_DIR -> + inventory_files := absolute :: !inventory_files; + let identity = + Platform.directory_identity ~path:absolute target_metadata + in + scan_directory ~relative ~collect_inventory:false + ~discover_requested:discover_modules ~collect_gentype ~identity + | _ -> + present_files := absolute :: !present_files; + inventory_files := absolute :: !inventory_files; + if discover_modules then on_missing absolute) + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> + if discover_modules then on_missing absolute) + | _ -> + present_files := absolute :: !present_files; + inventory_files := absolute :: !inventory_files; + if discover_modules then on_missing absolute) + | exception Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> + if discover_modules then on_missing absolute + +let resolve_active_features (config : Config.t) requested = + let active_features = Hashtbl.create 16 in + let visiting_features = Hashtbl.create 16 in + let features = Hashtbl.create (List.length config.features) in + List.iter + (fun (name, implied) -> Hashtbl.replace features name implied) + config.features; + let raise_feature_cycle feature visiting = + let chain = List.rev (feature :: visiting) |> String.concat " -> " in + raise (Error ("Cycle detected in `features` map: " ^ chain)) + in + let rec activate feature visiting = + if Hashtbl.mem visiting_features feature then + raise_feature_cycle feature visiting; + if not (Hashtbl.mem active_features feature) then ( + Hashtbl.add visiting_features feature (); + match Hashtbl.find_opt features feature with + | None -> + Hashtbl.remove visiting_features feature; + Hashtbl.add active_features feature () + | Some implied -> + List.iter (fun name -> activate name (feature :: visiting)) implied; + Hashtbl.remove visiting_features feature; + Hashtbl.add active_features feature ()) + in + List.iter (fun feature -> activate feature []) requested; + active_features + +let source_is_active ~prod ~all_features ~active_features + (source : Config.source) = + let feature_enabled = + all_features + || Option.fold ~none:true + ~some:(fun feature -> Hashtbl.mem active_features feature) + source.feature + in + (not (prod && source.is_dev)) && feature_enabled + +let active_sources (config : Config.t) ~prod ~features = + let active_features = + resolve_active_features config (Option.value features ~default:[]) + in + let all_features = Option.is_none features in + List.filter + (source_is_active ~prod ~all_features ~active_features) + config.sources + +let namespace_members ~entry modules = + modules + |> List.filter (fun module_ -> Some module_.name <> entry) + |> List.filter (fun module_ -> is_non_exotic_module_name module_.name) + +let scan_sources ~on_missing (config : Config.t) ~prod ~features + ~collect_inventory ~collect_gentype = + let active_features = + resolve_active_features config (Option.value features ~default:[]) + in + let all_features = features = None in + let visited_dirs = Hashtbl.create 32 in + let visited_inventory_dirs = Hashtbl.create 32 in + let visited_gentype_dirs = Hashtbl.create 32 in + let files = ref [] in + let inventory_files = ref [] in + let present_files = ref [] in + let gentype_dirs = ref [] in + config.sources + |> List.iter (fun (source : Config.source) -> + let feature_enabled = + all_features + || Option.fold ~none:true + ~some:(fun feature -> Hashtbl.mem active_features feature) + source.feature + in + let discover_modules = + source_is_active ~prod ~all_features ~active_features source + in + scan_source ~root:config.root source ~discover_modules ~on_missing + ~visited_dirs ~visited_inventory_dirs ~collect_inventory + ~collect_gentype:(collect_gentype && feature_enabled) + ~visited_gentype_dirs files inventory_files present_files gentype_dirs); + { + files = !files; + inventory_files = List.sort_uniq String.compare !inventory_files; + present_files = List.sort_uniq String.compare !present_files; + gentype_dirs = List.sort_uniq String.compare !gentype_dirs; + } + +let discover_for_cleanup + ?(on_missing = + fun path -> Printf.eprintf "Could not read folder %s\n%!" path) + (config : Config.t) ~prod = + let scanned = + scan_sources ~on_missing config ~prod ~features:None + ~collect_inventory:false ~collect_gentype:false + in + let implementations = + scanned.files + |> List.filter_map (fun source -> + match source.kind with + | Interface -> None + | Implementation -> Some source.file.path) + |> List.sort_uniq String.compare + in + implementations + +let discover_files + ?(on_missing = + fun path -> Printf.eprintf "Could not read folder %s\n%!" path) + (config : Config.t) ~prod ~features ~filter = + let matches_filter = + match filter with + | None -> fun _ -> true + | Some filter -> Source_filter.matches_basename filter + in + let scanned = + scan_sources ~on_missing config ~prod ~features ~collect_inventory:false + ~collect_gentype:false + in + scanned.files + |> List.filter_map (fun source -> + if matches_filter source.file.path then Some source.file.path else None) + |> List.sort_uniq String.compare + +let discover_with_inventory ?(on_orphan = fun _ -> ()) + ?(on_missing = + fun path -> Printf.eprintf "Could not read folder %s\n%!" path) + ?(display_root = Sys.getcwd ()) (config : Config.t) ~prod ~features ~filter + = + let matches_filter = + match filter with + | None -> fun _ -> true + | Some filter -> Source_filter.matches_basename filter + in + let scanned = + scan_sources ~on_missing config ~prod ~features ~collect_inventory:true + ~collect_gentype:(config.gentype_args <> []) + in + let files = scanned.files in + let table = Hashtbl.create (List.length files) in + List.iter + (fun source -> + let file = source.file in + let is_dev = source.is_dev in + let name = module_name file.path in + let implementation, interface, old_dev = + match Hashtbl.find_opt table name with + | None -> (None, None, is_dev) + | Some values -> values + in + match source.kind with + | Interface -> ( + match interface with + | Some previous -> + raise + (duplicate_error ~display_root config.root name previous.path + file.path) + | None -> + Hashtbl.replace table name + (implementation, Some file, old_dev || is_dev)) + | Implementation -> ( + match implementation with + | Some previous -> + raise + (duplicate_error ~display_root config.root name previous.path + file.path) + | None -> + Hashtbl.replace table name (Some file, interface, old_dev || is_dev))) + (List.filter (fun source -> matches_filter source.file.path) files); + Hashtbl.iter + (fun _ (implementation, interface, _) -> + match (implementation, interface) with + | Some implementation, Some interface + when Filename.remove_extension implementation.path + <> Filename.remove_extension interface.path -> + raise (interface_mismatch_error implementation.path interface.path) + | _ -> ()) + table; + Hashtbl.to_seq table + |> Seq.filter_map (fun (_, (implementation, interface, _)) -> + match (implementation, interface) with + | None, Some interface -> Some interface.path + | _ -> None) + |> List.of_seq |> List.sort String.compare |> List.iter on_orphan; + let modules = + Hashtbl.to_seq table + |> Seq.filter_map (fun (name, (implementation, interface, is_dev)) -> + match implementation with + | None -> None + | Some implementation -> + Some + { + name; + implementation = implementation.path; + interface = Option.map (fun file -> file.path) interface; + is_dev; + }) + |> List.of_seq + |> List.sort (fun a b -> String.compare a.name b.name) + in + let source_mtimes = + Hashtbl.to_seq_values table + |> Seq.flat_map (fun (implementation, interface, _) -> + List.to_seq (Option.to_list implementation @ Option.to_list interface)) + |> Seq.map (fun file -> (file.path, file.modified)) + |> List.of_seq + in + { + modules; + source_mtimes; + inventory_files = scanned.inventory_files; + present_files = scanned.present_files; + gentype_dirs = scanned.gentype_dirs; + } + +let ast_path path = + Filename.remove_extension path + ^ if Filename.extension path = ".resi" then ".iast" else ".ast" + +let compiler_basename config module_name = + Config.namespaced_module_name config.Config.namespace module_name + +(* Compiler artifacts preserve the source filename's case, while dependency + graph module names are capitalized. Keep those two names distinct. *) +let compiler_asset_basename config path = + let basename = path |> Filename.basename |> Filename.remove_extension in + match config.Config.namespace with + | Config.No_namespace -> basename + | Config.Namespace namespace -> basename ^ "-" ^ namespace + | Config.Namespace_with_entry {name; entry} -> + if entry = module_name path then basename else basename ^ "-@" ^ name diff --git a/rewatch-ocaml/source.mli b/rewatch-ocaml/source.mli new file mode 100644 index 00000000000..6809f8d0337 --- /dev/null +++ b/rewatch-ocaml/source.mli @@ -0,0 +1,60 @@ +(** Discovery keeps filesystem files separate from compilable modules. Commands + such as formatting can operate on orphan interfaces and duplicate basenames, + while compilation uses [module_] after pairing and duplicate validation. *) + +type module_ = { + name: string; + implementation: string; + interface: string option; + is_dev: bool; +} + +type discovery = { + modules: module_ list; + source_mtimes: (string * float) list; + inventory_files: string list; + present_files: string list; + gentype_dirs: string list; +} + +exception Error of string + +type source_kind = Implementation | Interface + +val source_kind : string -> source_kind option +val module_name : string -> string +val namespace_members : entry:string option -> module_ list -> module_ list + +val duplicate_error : + display_root:string -> string -> string -> string -> string -> exn + +val resolve_active_features : + Config.t -> string list -> (string, unit) Hashtbl.t + +val active_sources : + Config.t -> prod:bool -> features:string list option -> Config.source list + +val discover_for_cleanup : + ?on_missing:(string -> unit) -> Config.t -> prod:bool -> string list + +val discover_files : + ?on_missing:(string -> unit) -> + Config.t -> + prod:bool -> + features:string list option -> + filter:Source_filter.t option -> + string list + +val discover_with_inventory : + ?on_orphan:(string -> unit) -> + ?on_missing:(string -> unit) -> + ?display_root:string -> + Config.t -> + prod:bool -> + features:string list option -> + filter:Source_filter.t option -> + discovery + +val ast_path : string -> string +val compiler_basename : Config.t -> string -> string +val compiler_asset_basename : Config.t -> string -> string diff --git a/rewatch-ocaml/source_dirs.ml b/rewatch-ocaml/source_dirs.ml new file mode 100644 index 00000000000..5bb0c46bd98 --- /dev/null +++ b/rewatch-ocaml/source_dirs.ml @@ -0,0 +1,98 @@ +type scan = { + build_root: string; + scan_dirs: string list; + also_scan_build_root: bool; +} + +let scan_json scan = + `Assoc + [ + ("also_scan_build_root", `Bool scan.also_scan_build_root); + ("build_root", `String scan.build_root); + ("scan_dirs", `List (List.map (fun path -> `String path) scan.scan_dirs)); + ] + +let write ~root ~dirs ~packages ~scans = + let path = File_util.path_of_parts root ["lib"; "bs"; ".sourcedirs.json"] in + File_util.ensure_dir (Filename.dirname path); + let json = + `Assoc + [ + ("cmt_scan", `List (List.map scan_json scans)); + ("dirs", `List (List.map (fun path -> `String path) dirs)); + ("generated", `List []); + ( "pkgs", + `List + (List.map + (fun (name, path) -> `List [`String name; `String path]) + packages) ); + ("version", `Int 2); + ] + in + File_util.write_file_atomic ~ensure_parent:false ~perm:0o644 path + (Yojson.Safe.to_string json) + +let write_build ~(root_config : Config.t) session = + let packages = + Build_session.package_plan_values session + |> List.of_seq + |> List.sort (fun (left : Package_plan.t) right -> + String.compare left.root right.root) + in + packages + |> List.iter (fun package -> + if package.Package_plan.root <> root_config.root then + File_util.remove_file + (File_util.path_of_parts package.root + ["lib"; "bs"; ".sourcedirs.json"])); + let local_packages = + List.filter (fun package -> package.Package_plan.is_local) packages + in + let source_directories package = + package.Package_plan.modules + |> List.map (fun module_ -> Filename.dirname module_.Source.implementation) + |> List.sort_uniq String.compare + in + let relative_package_root package = + if package.Package_plan.root = root_config.root then "" + else Project_context.relative_to root_config.root package.root + in + let dirs = + local_packages + |> List.concat_map (fun package -> + let relative_root = relative_package_root package in + source_directories package + |> List.map (fun directory -> + if relative_root = "" then directory + else Filename.concat relative_root directory)) + |> List.sort_uniq String.compare + in + let package_roots = Hashtbl.create 16 in + local_packages + |> List.iter (fun package -> + package.Package_plan.dependencies + |> List.iter (fun dependency -> + Hashtbl.replace package_roots dependency.Package_plan.declaration.name + dependency.directory)); + let package_roots = + Hashtbl.to_seq package_roots + |> List.of_seq + |> List.sort (fun (left, _) (right, _) -> String.compare left right) + in + let scans = + local_packages + |> List.map (fun package -> + let relative_root = relative_package_root package in + let build_root = + if relative_root = "" then File_util.path_of_parts "" ["lib"; "bs"] + else File_util.path_of_parts relative_root ["lib"; "bs"] + in + { + build_root; + scan_dirs = source_directories package; + also_scan_build_root = true; + }) + |> List.sort (fun (left : scan) right -> + String.compare left.build_root right.build_root) + in + write ~root:root_config.root ~dirs ~packages:package_roots ~scans diff --git a/rewatch-ocaml/source_dirs.mli b/rewatch-ocaml/source_dirs.mli new file mode 100644 index 00000000000..4a0af8b3043 --- /dev/null +++ b/rewatch-ocaml/source_dirs.mli @@ -0,0 +1,7 @@ +type scan = { + build_root: string; + scan_dirs: string list; + also_scan_build_root: bool; +} + +val write_build : root_config:Config.t -> Build_session.t -> unit diff --git a/rewatch-ocaml/source_filter.ml b/rewatch-ocaml/source_filter.ml new file mode 100644 index 00000000000..bf864d8db6d --- /dev/null +++ b/rewatch-ocaml/source_filter.ml @@ -0,0 +1,60 @@ +type t = {pattern: string; regex: Re.re} + +let unsupported = Error "unsupported regular expression" + +(* A filter that compiles with different semantics can silently select the + wrong source set. Keep the accepted grammar to the CLI's shared safe subset; + the regex parser below rejects unsupported syntax outside this check. *) +let validate_compatibility pattern = + let length = String.length pattern in + let rec loop ~in_class ~class_start index = + if index >= length then Ok () + else + match pattern.[index] with + | '\\' when index + 1 < length -> ( + let escaped = pattern.[index + 1] in + match escaped with + | '0' .. '9' | 'Q' | 'E' | 'G' | 'Z' | 'e' | 'o' -> unsupported + | ('b' | 'W') when in_class -> unsupported + | _ -> loop ~in_class ~class_start (index + 2)) + | '(' + when (not in_class) + && index + 2 < length + && pattern.[index + 1] = '?' + && pattern.[index + 2] = '#' -> + unsupported + | '[' when not in_class -> + loop ~in_class:true ~class_start:(index + 1) (index + 1) + | '[' when index + 1 >= length || pattern.[index + 1] <> ':' -> + unsupported + | ']' when in_class && index > class_start -> + loop ~in_class:false ~class_start:0 (index + 1) + | ('&' | '-' | '~') as operator + when in_class && index + 1 < length && pattern.[index + 1] = operator -> + unsupported + | '-' + when in_class && index > class_start + && index + 1 < length + && pattern.[index + 1] <> ']' -> + let left = pattern.[index - 1] in + let right = pattern.[index + 1] in + let escaped_left = index >= 2 && pattern.[index - 2] = '\\' in + if escaped_left || left = '\\' || right = '\\' || left > right then + unsupported + else loop ~in_class ~class_start (index + 1) + | _ -> loop ~in_class ~class_start (index + 1) + in + loop ~in_class:false ~class_start:0 0 + +let compile pattern = + match validate_compatibility pattern with + | Error _ as error -> error + | Ok () -> ( + match Re.Perl.re_result pattern with + | Ok regex -> Ok {pattern; regex = Re.compile regex} + | Error `Parse_error -> Error "invalid regular expression" + | Error `Not_supported -> unsupported) + +let pattern filter = filter.pattern +let matches_basename filter path = + Re.execp filter.regex (Filename.basename path) diff --git a/rewatch-ocaml/source_filter.mli b/rewatch-ocaml/source_filter.mli new file mode 100644 index 00000000000..2b533c9dc5a --- /dev/null +++ b/rewatch-ocaml/source_filter.mli @@ -0,0 +1,5 @@ +type t + +val compile : string -> (t, string) result +val pattern : t -> string +val matches_basename : t -> string -> bool diff --git a/rewatch-ocaml/string_util.ml b/rewatch-ocaml/string_util.ml new file mode 100644 index 00000000000..6b26af1ad27 --- /dev/null +++ b/rewatch-ocaml/string_util.ml @@ -0,0 +1,22 @@ +(* OCaml 5.5 provides [String.includes], but Rewatch also supports OCaml 5.0. + The affixes checked here are short markers, so this allocation-free scan + keeps the compatibility implementation simpler than a general-purpose + substring-search algorithm. *) +let contains value substring = + let substring_length = String.length substring in + let last_start = String.length value - substring_length in + let rec matches_at start offset = + offset = substring_length + || String.get value (start + offset) = String.get substring offset + && matches_at start (offset + 1) + in + let rec search start = + start <= last_start && (matches_at start 0 || search (start + 1)) + in + search 0 + +let strip_prefix ~prefix value = + if String.starts_with ~prefix value then + String.sub value (String.length prefix) + (String.length value - String.length prefix) + else value diff --git a/rewatch-ocaml/string_util.mli b/rewatch-ocaml/string_util.mli new file mode 100644 index 00000000000..cda9b6907ff --- /dev/null +++ b/rewatch-ocaml/string_util.mli @@ -0,0 +1,2 @@ +val contains : string -> string -> bool +val strip_prefix : prefix:string -> string -> string diff --git a/rewatch-ocaml/tests/basic/rescript.json b/rewatch-ocaml/tests/basic/rescript.json new file mode 100644 index 00000000000..6abd6654a34 --- /dev/null +++ b/rewatch-ocaml/tests/basic/rescript.json @@ -0,0 +1,6 @@ +{ + "name": "rewatch-ocaml-basic", + "sources": {"dir": "src", "subdirs": true}, + "package-specs": {"module": "esmodule", "in-source": true}, + "suffix": ".mjs" +} diff --git a/rewatch-ocaml/tests/basic/src/A.res b/rewatch-ocaml/tests/basic/src/A.res new file mode 100644 index 00000000000..5f5aa16a1ae --- /dev/null +++ b/rewatch-ocaml/tests/basic/src/A.res @@ -0,0 +1 @@ +let value = 41 diff --git a/rewatch-ocaml/tests/basic/src/Authored.js b/rewatch-ocaml/tests/basic/src/Authored.js new file mode 100644 index 00000000000..e65eaf2a3a6 --- /dev/null +++ b/rewatch-ocaml/tests/basic/src/Authored.js @@ -0,0 +1 @@ +authored same-stem JavaScript diff --git a/rewatch-ocaml/tests/basic/src/Authored.res b/rewatch-ocaml/tests/basic/src/Authored.res new file mode 100644 index 00000000000..3c37c33b59a --- /dev/null +++ b/rewatch-ocaml/tests/basic/src/Authored.res @@ -0,0 +1 @@ +let value = 1 diff --git a/rewatch-ocaml/tests/basic/src/B.res b/rewatch-ocaml/tests/basic/src/B.res new file mode 100644 index 00000000000..f12af2ffc7d --- /dev/null +++ b/rewatch-ocaml/tests/basic/src/B.res @@ -0,0 +1 @@ +let answer = A.value + 1 diff --git a/rewatch-ocaml/tests/basic/src/WithInterface.res b/rewatch-ocaml/tests/basic/src/WithInterface.res new file mode 100644 index 00000000000..ec69b5dd4a5 --- /dev/null +++ b/rewatch-ocaml/tests/basic/src/WithInterface.res @@ -0,0 +1 @@ +let value = B.answer diff --git a/rewatch-ocaml/tests/basic/src/WithInterface.resi b/rewatch-ocaml/tests/basic/src/WithInterface.resi new file mode 100644 index 00000000000..14829e3b698 --- /dev/null +++ b/rewatch-ocaml/tests/basic/src/WithInterface.resi @@ -0,0 +1 @@ +let value: int diff --git a/rewatch-ocaml/tests/check_canonical_test_coverage.sh b/rewatch-ocaml/tests/check_canonical_test_coverage.sh new file mode 100755 index 00000000000..db93f31a404 --- /dev/null +++ b/rewatch-ocaml/tests/check_canonical_test_coverage.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +tests="$root/rewatch/tests" +inventory=$(mktemp) +referenced=$(mktemp) +cleanup() { + rm -f "$inventory" "$referenced" +} +trap cleanup EXIT + +for file in "$tests"/*/*.sh; do + relative=${file#"$tests/"} + printf './%s\n' "$relative" +done | sort >"$inventory" + +sed -n 's/^\(\.\/[^ ]*\.sh\).*$/\1/p' "$tests/suite.sh" | sort \ + >"$referenced" + +duplicates=$(uniq -d "$referenced") +missing=$(comm -23 "$inventory" "$referenced") +stale=$(comm -13 "$inventory" "$referenced") +if [[ -n "$duplicates" || -n "$missing" || -n "$stale" ]]; then + [[ -z "$duplicates" ]] \ + || printf 'Canonical tests referenced more than once:\n%s\n' "$duplicates" >&2 + [[ -z "$missing" ]] \ + || printf 'Canonical tests omitted from suite.sh:\n%s\n' "$missing" >&2 + [[ -z "$stale" ]] \ + || printf 'Stale canonical test references in suite.sh:\n%s\n' "$stale" >&2 + exit 1 +fi + +total=$(wc -l <"$inventory" | tr -d ' ') +printf 'Canonical integration tests: %s; all referenced exactly once\n' "$total" diff --git a/rewatch-ocaml/tests/check_command_validation.sh b/rewatch-ocaml/tests/check_command_validation.sh new file mode 100755 index 00000000000..0b5a32cadf7 --- /dev/null +++ b/rewatch-ocaml/tests/check_command_validation.sh @@ -0,0 +1,2029 @@ +#!/bin/bash +set -eu + +root=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +rust=${1:-$root/rewatch/target/debug/rescript} +ocaml=${2:-$root/_build/default/rewatch-ocaml/rescript_ocaml.exe} +rust=$(realpath "$rust") +ocaml=$(realpath "$ocaml") +bsc_test_proxy="$root/_build/default/tests/rewatch_ounit_tests/rewatch_bsc_test_proxy.exe" +work=$(mktemp -d "${TMPDIR:-/tmp}/rewatch-command-validation-XXXXXX") +# Compiler diagnostics contain canonical paths. Resolve platform aliases such +# as macOS's /var -> /private/var before deriving paths used for comparison. +work=$(realpath "$work") +native_work=$work +native_short_work=$work +windows_posix_shell=false +case $(uname -s) in + MINGW*|MSYS*) + windows_posix_shell=true + native_work=$(cd "$work" && pwd -W) + native_user=$(cygpath -w "$USERPROFILE" | tr '\\' '/') + native_short_user=$(cygpath -w -s "$USERPROFILE" | tr '\\' '/') + native_short_work=${native_work/"$native_user"/"$native_short_user"} + bsc_test_proxy=$(cygpath -aw \ + "$root/_build/default/tests/rewatch_ounit_tests/rewatch_bsc_test_proxy.exe") + ;; +esac +delete_source_bsc=$bsc_test_proxy +delete_parse_sources_bsc=$bsc_test_proxy +delete_ast_bsc=$bsc_test_proxy +lock_owner_pid() { + local shell_pid=$1 + if $windows_posix_shell; then + # MSYS assigns a synthetic PID to native Windows children. Lock files are + # consumed by native executables and therefore must contain the WINPID. + ps -p "$shell_pid" -l | awk 'NR == 2 { print $4 }' + else + printf '%s\n' "$shell_pid" + fi +} +directory_link() { + local target=$1 + local link=$2 + if $windows_posix_shell; then + LINK_PATH=$(cygpath -aw "$link") TARGET_PATH=$(cygpath -aw "$target") \ + powershell.exe -NoProfile -NonInteractive -Command \ + '$ErrorActionPreference = "Stop"; $null = New-Item -ItemType Junction -Path $env:LINK_PATH -Target $env:TARGET_PATH' + else + ln -s "$target" "$link" + fi +} +file_link_if_supported() { + local target=$1 + local link=$2 + if $windows_posix_shell; then + LINK_PATH=$(cygpath -aw "$link") TARGET_PATH=$(cygpath -aw "$target") \ + powershell.exe -NoProfile -NonInteractive -Command \ + '$ErrorActionPreference = "Stop"; $null = New-Item -ItemType SymbolicLink -Path $env:LINK_PATH -Target $env:TARGET_PATH' \ + >/dev/null 2>&1 + else + ln -s "$target" "$link" + fi +} +command_work=$native_work +command_path() { + case $1 in + "$work"*) printf '%s\n' "$command_work${1#"$work"}" ;; + *) printf '%s\n' "$1" ;; + esac +} +terminate_and_wait() { + local pid=$1 + local label=$2 + kill -TERM "$pid" + set +e + wait "$pid" + local status=$? + set -e + if [ "$status" -ne 0 ] && \ + { ! $windows_posix_shell || [ "$status" -ne 143 ]; }; then + printf '%s exited with status %s after shutdown\n' "$label" "$status" >&2 + return 1 + fi +} +background_pids="" +cleanup() { + for pid in $background_pids; do + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + rm -rf "$work" +} +trap cleanup EXIT + +project="$work/project" +mkdir -p "$project/src" "$work/orphan" "$work/empty" "$work/malformed" +mkdir -p "$work/malformed-parent/child/src" "$work/config-directory/rescript.json" +mkdir -p "$work/missing-dependency/src" +mkdir -p "$work/malformed-lock/src" "$work/malformed-lock/lib" +mkdir -p "$work/watch-lock-order/src" "$work/watch-lock-order/lib" +mkdir -p "$work/signal-lock/src" "$work/signal-lock/lib" +mkdir -p "$work/signal-lock-owner/src" +mkdir -p "$work/interface-mismatch/src" +mkdir -p "$work/exotic-module-rust/src" "$work/exotic-module-ocaml/src" +mkdir -p "$work/filter-basename-rust/src/nested" \ + "$work/filter-basename-ocaml/src/nested" +mkdir -p "$work/external-dev-source/src" \ + "$work/external-dev-source/node_modules/dep/src" \ + "$work/external-dev-source/node_modules/dep/test" +mkdir -p "$work/external-dev-permission/src" \ + "$work/external-dev-permission/node_modules/a" \ + "$work/external-dev-permission/node_modules/b" +mkdir -p "$work/active-permission/node_modules/a" \ + "$work/active-permission/node_modules/b" +mkdir -p "$work/source-path-file" +mkdir -p "$work/missing-runtime-package" +mkdir -p "$work/clean-missing-bsc/src" "$work/clean-missing-bsc/lib/bs" +mkdir -p "$work/clean-missing-runtime/src" \ + "$work/clean-missing-runtime/lib/bs" +for implementation in rust ocaml; do + mkdir -p "$work/clean-duplicate-$implementation/src/one" \ + "$work/clean-duplicate-$implementation/src/two" \ + "$work/clean-duplicate-$implementation/lib/bs" +done +mkdir -p "$work/missing-source-folder/src" \ + "$work/missing-source-folder/node_modules/dep" +mkdir -p "$work/dependency-without-sources/src" \ + "$work/dependency-without-sources/node_modules/dep" +mkdir -p "$work/default-feature-cycle/src" +mkdir -p "$work/format-feature-cycle/src" \ + "$work/format-feature-cycle/node_modules/dep/src" +mkdir -p "$work/format-source-selection/src" \ + "$work/format-source-selection/node_modules/installed" \ + "$work/format-source-selection/packages/local/base" \ + "$work/format-source-selection/packages/local/native" \ + "$work/format-source-selection/packages/local/other" +mkdir -p "$work/package-name-mismatch/src" "$work/malformed-package-json/src" +mkdir -p "$work/failed-js-post-build/src" +mkdir -p "$work/mismatched-dependency/src" \ + "$work/mismatched-dependency/node_modules/dep/src" +mkdir -p "$work/configless-dependency/src" \ + "$work/configless-dependency/node_modules/no-config" +mkdir -p "$work/malformed-dependency/src" \ + "$work/malformed-dependency/node_modules/bad-config" +mkdir -p "$work/duplicate-dependency/src" \ + "$work/duplicate-dependency/node_modules/a/src" \ + "$work/duplicate-dependency/node_modules/shared/src" \ + "$work/duplicate-dependency/node_modules/a/node_modules/shared/src" +mkdir -p "$work/publication-race-rust/src" \ + "$work/publication-race-ocaml/src" +mkdir -p "$work/ast-race-rust/src" "$work/ast-race-ocaml/src" +mkdir -p "$work/parse-source-race-rust/src" \ + "$work/parse-source-race-ocaml/src" +mkdir -p "$work/watch-config-rust/src" "$work/watch-config-ocaml/src" +mkdir -p "$work/watch-retained-graph/src" +mkdir -p "$work/watch-dependency-recovery/src" \ + "$work/watch-dependency-recovery/node_modules" \ + "$work/watch-dependency-recovery/packages/dep/src" +mkdir -p "$work/watch-dependency-install/src" \ + "$work/watch-dependency-install/node_modules" +mkdir -p "$work/watch-dependency-fallback/src" \ + "$work/watch-dependency-fallback/node_modules/dep" \ + "$work/watch-dependency-fallback/packages/dep/src" \ + "$work/node_modules" +mkdir -p "$work/watch-symlink-target/src" "$work/watch-symlink-external/sub" +mkdir -p "$work/watch-feature-scope/src" "$work/watch-feature-scope/inactive" +mkdir -p "$work/watch-filter-rust/src" "$work/watch-filter-rust/inactive" \ + "$work/watch-filter-ocaml/src" "$work/watch-filter-ocaml/inactive" +mkdir -p "$work/quiet-watch-rust/src" "$work/quiet-watch-ocaml/src" +printf '{"name":"command-validation","sources":["src"]}\n' \ + >"$project/rescript.json" +printf 'let value = 1\n' >"$project/src/A.res" +printf 'not a ReScript source\n' >"$project/src/A.txt" +printf '{"name":"signal-lock","sources":["src"]}\n' \ + >"$work/signal-lock/rescript.json" +printf 'let value = 1\n' >"$work/signal-lock/src/A.res" +printf '{"name":"signal-lock-owner","sources":["src"]}\n' \ + >"$work/signal-lock-owner/rescript.json" +printf 'let value = 1\n' >"$work/signal-lock-owner/src/A.res" +printf '{"name":"watch-lock-order","sources":["src"]}\n' \ + >"$work/watch-lock-order/rescript.json" +printf 'let value = 1\n' >"$work/watch-lock-order/src/A.res" +mkdir -p "$work/redirected-parse-fixture/src" +mkdir -p "$work/multiple-parse-errors/src" +mkdir -p "$work/redirected-config-diagnostics/src" +printf '{"name":"parse-output","sources":["src"]}\n' \ + >"$work/redirected-parse-fixture/rescript.json" +printf 'let value =\n' >"$work/redirected-parse-fixture/src/A.res" +printf '{"name":"multiple-parse-errors","sources":["src"]}\n' \ + >"$work/multiple-parse-errors/rescript.json" +printf 'let value =\n' >"$work/multiple-parse-errors/src/A.res" +printf 'let other =\n' >"$work/multiple-parse-errors/src/B.res" +printf '%s\n' \ + '{"name":"config-diagnostics","sources":["src"],"bsc-flags":[],"ignored-dirs":[],"future-field":true}' \ + >"$work/redirected-config-diagnostics/rescript.json" +printf 'let value = 1\n' >"$work/redirected-config-diagnostics/src/A.res" +printf 'process.stderr.write("hook failed\\n"); process.exit(7)\n' \ + >"$work/failing-after-build.js" +printf 'let value = 1\n' >"$work/orphan/A.res" +printf '{ invalid json\n' >"$work/malformed/rescript.json" +printf '{ invalid json\n' >"$work/malformed-parent/rescript.json" +printf '{"name":"child","sources":["src"]}\n' \ + >"$work/malformed-parent/child/rescript.json" +printf 'let value = 1\n' >"$work/malformed-parent/child/src/A.res" +printf '{"name":"missing-dependency","sources":["src"],"dependencies":["absent"]}\n' \ + >"$work/missing-dependency/rescript.json" +printf 'let value = 1\n' >"$work/missing-dependency/src/A.res" +printf '{"name":"malformed-lock","sources":["src"]}\n' \ + >"$work/malformed-lock/rescript.json" +printf 'let value = 1\n' >"$work/malformed-lock/src/A.res" +printf '{"name":"interface-mismatch","sources":["src"]}\n' \ + >"$work/interface-mismatch/rescript.json" +printf 'let value = 1\n' >"$work/interface-mismatch/src/lower.res" +printf 'let value: int\n' >"$work/interface-mismatch/src/Lower.resi" +for implementation in rust ocaml; do + printf '{"name":"exotic-module","namespace":"Ns","sources":["src"]}\n' \ + >"$work/exotic-module-$implementation/rescript.json" + printf 'let value = 1\n' \ + >"$work/exotic-module-$implementation/src/Main.res" + printf 'let value = 2\n' \ + >"$work/exotic-module-$implementation/src/foo-bar.res" + printf '{"name":"filter-basename","sources":[{"dir":"src","subdirs":true}]}\n' \ + >"$work/filter-basename-$implementation/rescript.json" + printf 'let value = 1\n' \ + >"$work/filter-basename-$implementation/src/nested/A.res" + printf 'let value = 2\n' \ + >"$work/filter-basename-$implementation/src/nested/B2.res" +done +printf '{"name":"external-dev-source","sources":["src"],"dependencies":["dep"]}\n' \ + >"$work/external-dev-source/rescript.json" +printf 'let value = DepPublic.value\n' \ + >"$work/external-dev-source/src/App.res" +printf '{"name":"dep","sources":["src",{"dir":"test","type":"dev"}]}\n' \ + >"$work/external-dev-source/node_modules/dep/rescript.json" +printf 'let value = 1\n' \ + >"$work/external-dev-source/node_modules/dep/src/DepPublic.res" +printf 'this is deliberately invalid ReScript\n' \ + >"$work/external-dev-source/node_modules/dep/test/DevOnly.res" +printf '{"name":"root","sources":["src"],"dependencies":["a","b"]}\n' \ + >"$work/external-dev-permission/rescript.json" +printf '{"name":"a","sources":[],"dev-dependencies":["b"]}\n' \ + >"$work/external-dev-permission/node_modules/a/rescript.json" +printf '{"name":"b","sources":[],"allowed-dependents":["root"]}\n' \ + >"$work/external-dev-permission/node_modules/b/rescript.json" +printf '{"name":"root","sources":[],"dependencies":["a","b"]}\n' \ + >"$work/active-permission/rescript.json" +printf '{"name":"a","sources":[],"allowed-dependents":["someone-else"]}\n' \ + >"$work/active-permission/node_modules/a/rescript.json" +printf '{"name":"b","sources":[],"allowed-dependents":["someone-else"]}\n' \ + >"$work/active-permission/node_modules/b/rescript.json" +printf '{"name":"source-path-file","sources":["src"]}\n' \ + >"$work/source-path-file/rescript.json" +printf 'not a directory\n' >"$work/source-path-file/src" +printf '{"name":"missing-runtime-package","sources":[]}\n' \ + >"$work/missing-runtime-package/rescript.json" +for clean_project in clean-missing-bsc clean-missing-runtime; do + printf '{"name":"%s","sources":["src"]}\n' "$clean_project" \ + >"$work/$clean_project/rescript.json" + printf 'let value = 1\n' >"$work/$clean_project/src/A.res" + printf 'owned compiler artifact\n' >"$work/$clean_project/lib/bs/marker" +done +for implementation in rust ocaml; do + duplicate_clean="$work/clean-duplicate-$implementation" + printf '%s\n' \ + '{"name":"duplicate-clean","sources":{"dir":"src","subdirs":true},"package-specs":{"module":"esmodule","in-source":true}}' \ + >"$duplicate_clean/rescript.json" + printf 'let value = 1\n' >"$duplicate_clean/src/one/A.res" + printf 'let value = 2\n' >"$duplicate_clean/src/two/A.res" + printf 'generated\n' >"$duplicate_clean/src/one/A.js" + printf 'generated\n' >"$duplicate_clean/src/two/A.js" + printf 'owned compiler artifact\n' >"$duplicate_clean/lib/bs/marker" +done +printf '{"name":"missing-source-folder","sources":["src"],"dependencies":["dep"]}\n' \ + >"$work/missing-source-folder/rescript.json" +printf 'let value = 1\n' >"$work/missing-source-folder/src/App.res" +printf '{"name":"dep","sources":["missing"]}\n' \ + >"$work/missing-source-folder/node_modules/dep/rescript.json" +printf '{"name":"dependency-without-sources","sources":["src"],"dependencies":["dep"]}\n' \ + >"$work/dependency-without-sources/rescript.json" +printf 'let value = 1\n' >"$work/dependency-without-sources/src/App.res" +printf '{"name":"dep"}\n' \ + >"$work/dependency-without-sources/node_modules/dep/rescript.json" +printf '{"name":"dep"}\n' \ + >"$work/dependency-without-sources/node_modules/dep/package.json" +printf '{"name":"default-feature-cycle","sources":["src"],"features":{"a":["b"],"b":["a"]}}\n' \ + >"$work/default-feature-cycle/rescript.json" +printf 'let value = 1\n' >"$work/default-feature-cycle/src/App.res" +printf '{"name":"format-feature-cycle","sources":["src"],"dependencies":[{"name":"dep","features":["a"]}]}\n' \ + >"$work/format-feature-cycle/rescript.json" +printf 'let value = 1\n' >"$work/format-feature-cycle/src/App.res" +printf '{"name":"dep","sources":["src"],"features":{"a":["b"],"b":["a"]}}\n' \ + >"$work/format-feature-cycle/node_modules/dep/rescript.json" +printf '{"name":"dep"}\n' \ + >"$work/format-feature-cycle/node_modules/dep/package.json" +printf 'let value = 1\n' \ + >"$work/format-feature-cycle/node_modules/dep/src/Dep.res" +printf '{"name":"format-source-selection","sources":["src"],"dependencies":["installed",{"name":"local","features":["native"]}]}\n' \ + >"$work/format-source-selection/rescript.json" +printf '{"name":"installed","sources":["missing"]}\n' \ + >"$work/format-source-selection/node_modules/installed/rescript.json" +printf '{"name":"local","sources":["base",{"dir":"native","feature":"native"},{"dir":"other","feature":"other"}]}\n' \ + >"$work/format-source-selection/packages/local/rescript.json" +printf 'let value=1\n' \ + >"$work/format-source-selection/packages/local/base/Base.res" +printf 'let value=2\n' \ + >"$work/format-source-selection/packages/local/native/Native.res" +printf 'let value=3\n' \ + >"$work/format-source-selection/packages/local/other/Other.res" +directory_link "$work/format-source-selection/packages/local" \ + "$work/format-source-selection/node_modules/local" +printf '{"name":"config-name","sources":["src"]}\n' \ + >"$work/package-name-mismatch/rescript.json" +printf '{"name":"package-name"}\n' >"$work/package-name-mismatch/package.json" +printf 'let value = 1\n' >"$work/package-name-mismatch/src/A.res" +printf '{"name":"malformed-package-json","sources":["src"]}\n' \ + >"$work/malformed-package-json/rescript.json" +printf '{invalid\n' >"$work/malformed-package-json/package.json" +printf 'let value = 1\n' >"$work/malformed-package-json/src/A.res" +printf '{"name":"failed-js-post-build","sources":["src"],"js-post-build":{"cmd":"exit 7"}}\n' \ + >"$work/failed-js-post-build/rescript.json" +printf 'let value = 1\n' >"$work/failed-js-post-build/src/A.res" +printf '{"name":"mismatched-dependency","sources":["src"],"dependencies":["dep"]}\n' \ + >"$work/mismatched-dependency/rescript.json" +printf 'let value = Dep.value\n' >"$work/mismatched-dependency/src/A.res" +printf '{"name":"dep","sources":["src"]}\n' \ + >"$work/mismatched-dependency/node_modules/dep/rescript.json" +printf '{"name":"different-name"}\n' \ + >"$work/mismatched-dependency/node_modules/dep/package.json" +printf 'let value = 1\n' \ + >"$work/mismatched-dependency/node_modules/dep/src/Dep.res" +printf '{"name":"configless-dependency","sources":["src"],"dependencies":["no-config"]}\n' \ + >"$work/configless-dependency/rescript.json" +printf 'let value = 1\n' >"$work/configless-dependency/src/A.res" +printf '{"name":"malformed-dependency","sources":["src"],"dependencies":["bad-config"]}\n' \ + >"$work/malformed-dependency/rescript.json" +printf 'let value = 1\n' >"$work/malformed-dependency/src/A.res" +printf '{ invalid json\n' \ + >"$work/malformed-dependency/node_modules/bad-config/rescript.json" +printf '{"name":"duplicate-dependency","sources":["src"],"dependencies":["a","shared"]}\n' \ + >"$work/duplicate-dependency/rescript.json" +printf 'let value = Shared.value + A.value\n' \ + >"$work/duplicate-dependency/src/Main.res" +printf '{"name":"a","sources":["src"],"dependencies":["shared"]}\n' \ + >"$work/duplicate-dependency/node_modules/a/rescript.json" +printf 'let value = Shared.value\n' \ + >"$work/duplicate-dependency/node_modules/a/src/A.res" +printf '{"name":"shared","sources":["src"]}\n' \ + >"$work/duplicate-dependency/node_modules/shared/rescript.json" +printf 'let value = 1\n' \ + >"$work/duplicate-dependency/node_modules/shared/src/Shared.res" +printf '{"name":"shared","sources":["src"]}\n' \ + >"$work/duplicate-dependency/node_modules/a/node_modules/shared/rescript.json" +printf 'let value = 2\n' \ + >"$work/duplicate-dependency/node_modules/a/node_modules/shared/src/Shared.res" +printf '{"name":"publication-race","sources":["src"]}\n' \ + >"$work/publication-race-rust/rescript.json" +cp "$work/publication-race-rust/rescript.json" \ + "$work/publication-race-ocaml/rescript.json" +printf 'let value = 1\n' >"$work/publication-race-rust/src/A.res" +cp "$work/publication-race-rust/src/A.res" \ + "$work/publication-race-ocaml/src/A.res" +printf '{"name":"ast-race","sources":["src"]}\n' \ + >"$work/ast-race-rust/rescript.json" +cp "$work/ast-race-rust/rescript.json" "$work/ast-race-ocaml/rescript.json" +printf 'let value = 1\n' >"$work/ast-race-rust/src/A.res" +cp "$work/ast-race-rust/src/A.res" "$work/ast-race-ocaml/src/A.res" +printf '{"name":"parse-source-race","sources":["src"]}\n' \ + >"$work/parse-source-race-rust/rescript.json" +cp "$work/parse-source-race-rust/rescript.json" \ + "$work/parse-source-race-ocaml/rescript.json" +for implementation in rust ocaml; do + printf 'let value = 1\n' \ + >"$work/parse-source-race-$implementation/src/A.res" + printf 'let value = 2\n' \ + >"$work/parse-source-race-$implementation/src/B.res" +done +printf '{"name":"watch-config","sources":["src"]}\n' \ + >"$work/watch-config-rust/rescript.json" +cp "$work/watch-config-rust/rescript.json" \ + "$work/watch-config-ocaml/rescript.json" +printf 'let value = 1\n' >"$work/watch-config-rust/src/A.res" +cp "$work/watch-config-rust/src/A.res" "$work/watch-config-ocaml/src/A.res" +printf '{"name":"watch-retained-graph","sources":["src"]}\n' \ + >"$work/watch-retained-graph/rescript.json" +printf 'let value = 1\n' >"$work/watch-retained-graph/src/A.res" +printf 'let oldValue = 1\n' >"$work/watch-retained-graph/src/C.res" +printf 'let value = A.value\n' >"$work/watch-retained-graph/src/B.res" +printf '{"name":"watch-dependency-recovery","sources":["src"],"dependencies":["dep"]}\n' \ + >"$work/watch-dependency-recovery/rescript.json" +printf 'let value = 1\n' >"$work/watch-dependency-recovery/src/A.res" +printf '{ invalid json\n' \ + >"$work/watch-dependency-recovery/packages/dep/rescript.json" +printf 'let dependency = 1\n' \ + >"$work/watch-dependency-recovery/packages/dep/src/Dep.res" +directory_link "$work/watch-dependency-recovery/packages/dep" \ + "$work/watch-dependency-recovery/node_modules/dep" +printf '{"name":"watch-dependency-install","sources":["src"],"dependencies":["@scope/dep"]}\n' \ + >"$work/watch-dependency-install/rescript.json" +printf 'let value = 1\n' >"$work/watch-dependency-install/src/A.res" +printf '{"name":"watch-feature-scope","sources":["src",{"dir":"inactive","feature":"inactive"}]}\n' \ + >"$work/watch-feature-scope/rescript.json" +printf 'let value = 1\n' >"$work/watch-feature-scope/src/A.res" +printf 'let inactive = 1\n' >"$work/watch-feature-scope/inactive/Inactive.res" +printf '{"name":"watch-dependency-fallback","sources":["src"],"dependencies":["dep"]}\n' \ + >"$work/watch-dependency-fallback/rescript.json" +printf 'let value = 1\n' >"$work/watch-dependency-fallback/src/A.res" +printf '{"name":"dep","sources":["src"]}\n' \ + >"$work/watch-dependency-fallback/packages/dep/rescript.json" +printf 'let dependency = 1\n' \ + >"$work/watch-dependency-fallback/packages/dep/src/Dep.res" +printf '{"name":"watch-symlink-target","sources":["src"]}\n' \ + >"$work/watch-symlink-target/rescript.json" +printf 'let linked = 1\n' >"$work/watch-symlink-external/sub/Linked.res" +file_symlinks_supported=true +if ! file_link_if_supported "$work/watch-symlink-external/sub/Linked.res" \ + "$work/watch-symlink-target/src/Linked.res"; then + file_symlinks_supported=false +fi +for implementation in rust ocaml; do + printf '{"name":"watch-filter","sources":["src",{"dir":"inactive","feature":"inactive"}]}\n' \ + >"$work/watch-filter-$implementation/rescript.json" + printf 'let value = 1\n' \ + >"$work/watch-filter-$implementation/src/Include.res" + printf 'let value = 10\n' \ + >"$work/watch-filter-$implementation/src/Exclude.res" + printf 'let value = 20\n' \ + >"$work/watch-filter-$implementation/inactive/Inactive.res" + printf '{"name":"quiet-watch","sources":["src"]}\n' \ + >"$work/quiet-watch-$implementation/rescript.json" + printf 'let value = 1\n' \ + >"$work/quiet-watch-$implementation/src/A.res" +done +printf 'require("fs").appendFileSync(process.env.REWATCH_WATCH_FILTER_MARKER, "done\\n")\n' \ + >"$work/watch-filter-marker.js" +printf '%s\n' \ + '#!/bin/sh' \ + 'if [ -f "$REWATCH_SCOPE_BLOCK_REQUEST" ] && [ ! -f "$REWATCH_SCOPE_BLOCK_STARTED" ]; then' \ + ' : >"$REWATCH_SCOPE_BLOCK_STARTED"' \ + ' attempts=0' \ + ' while [ ! -f "$REWATCH_SCOPE_BLOCK_RELEASE" ] && [ "$attempts" -lt 200 ]; do' \ + ' attempts=$((attempts + 1))' \ + ' sleep 0.05' \ + ' done' \ + 'fi' \ + 'exec "$REWATCH_SCOPE_REAL_BSC" "$@"' \ + >"$work/watch-scope-bsc.sh" +chmod +x "$work/watch-scope-bsc.sh" +watch_scope_bsc="$work/watch-scope-bsc.sh" +if $windows_posix_shell; then + watch_scope_bsc=$bsc_test_proxy +fi + +default_bsc=$root/_build/default/compiler/bsc/rescript_compiler_main.exe +default_runtime=$root/packages/@rescript/runtime +case $(uname -s) in + MINGW*|MSYS*) + default_bsc=$(cygpath -w "$default_bsc") + default_runtime=$(cygpath -w "$default_runtime") + ;; +esac +export RESCRIPT_BSC_EXE=${RESCRIPT_BSC_EXE:-$default_bsc} +export RESCRIPT_RUNTIME=${RESCRIPT_RUNTIME:-$default_runtime} + +classify() { + case "$1" in + 0) printf accept ;; + 101) printf panic ;; + 2) printf exit2 ;; + *) printf reject ;; + esac +} + +strip_ansi() { + LC_ALL=C sed $'s/\033\\[[0-9;]*m//g' "$1" +} + +normalize_project_path() { + local input=$1 + local output=$2 + local project=$3 + local native_project=$project + local native_short_project=$project + case $(uname -s) in + MINGW*|MSYS*) + native_project=$(cd "$project" && pwd -W) + native_short_project=${native_project/"$native_user"/"$native_short_user"} + ;; + esac + PROJECT_PATH=$project PROJECT_NATIVE_PATH=$native_project \ + PROJECT_SHORT_PATH=$native_short_project node -e ' + const fs = require("fs"); + let text = fs.readFileSync(process.argv[1], "utf8"); + for (const project of [process.env.PROJECT_PATH, process.env.PROJECT_NATIVE_PATH, process.env.PROJECT_SHORT_PATH]) { + for (const spelling of [project, project.replaceAll("/", "\\")]) { + text = text.split(spelling).join(""); + } + } + text = text.replaceAll("\\", "/"); + text = text.replaceAll(".//", "./"); + fs.writeFileSync(process.argv[2], text); + ' "$input" "$output" +} + +checked=0 +run_case() { + name=$1 + rust_expected=$2 + ocaml_expected=$3 + shift 3 + set +e + "$rust" "$@" >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + "$ocaml" "$@" >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + set -e + rust_actual=$(classify "$rust_status") + ocaml_actual=$(classify "$ocaml_status") + if [ "$rust_actual" != "$rust_expected" ] || \ + [ "$ocaml_actual" != "$ocaml_expected" ]; then + printf '%s: expected Rust=%s/OCaml=%s, got Rust=%s/OCaml=%s\n' \ + "$name" "$rust_expected" "$ocaml_expected" \ + "$rust_status" "$ocaml_status" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 + fi + checked=$((checked + 1)) +} + +run_cwd_case() { + name=$1 + rust_expected=$2 + ocaml_expected=$3 + cwd=$4 + shift 4 + set +e + (cd "$cwd" && "$rust" "$@") >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + (cd "$cwd" && "$ocaml" "$@") >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + set -e + rust_actual=$(classify "$rust_status") + ocaml_actual=$(classify "$ocaml_status") + if [ "$rust_actual" != "$rust_expected" ] || \ + [ "$ocaml_actual" != "$ocaml_expected" ]; then + printf '%s: expected Rust=%s/OCaml=%s, got Rust=%s/OCaml=%s\n' \ + "$name" "$rust_expected" "$ocaml_expected" \ + "$rust_status" "$ocaml_status" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 + fi + checked=$((checked + 1)) +} + +require_same_output() { + name=$1 + if ! cmp -s "$work/rust.out" "$work/ocaml.out" || \ + ! cmp -s "$work/rust.err" "$work/ocaml.err"; then + echo "$name: Rust and OCaml output differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 + fi +} + +require_both_errors_contain() { + local name=$1 + local fragment=$2 + local native_fragment=${fragment/"$work"/"$native_work"} + local native_short_fragment=${fragment/"$work"/"$native_short_work"} + if ! { grep -F "$fragment" "$work/rust.err" >/dev/null || \ + tr '\\' '/' <"$work/rust.err" | grep -F "$native_fragment" >/dev/null || \ + tr '\\' '/' <"$work/rust.err" | grep -F "$native_short_fragment" >/dev/null; } || \ + ! { grep -F "$fragment" "$work/ocaml.err" >/dev/null || \ + tr '\\' '/' <"$work/ocaml.err" | grep -F "$native_fragment" >/dev/null || \ + tr '\\' '/' <"$work/ocaml.err" | grep -F "$native_short_fragment" >/dev/null; }; then + printf '%s: expected both errors to contain %s\n' "$name" "$fragment" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 + fi +} + +run_missing_bsc_case() { + name=$1 + shift + set +e + RESCRIPT_BSC_EXE="$work/missing-bsc" "$rust" "$@" \ + >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + RESCRIPT_BSC_EXE="$work/missing-bsc" "$ocaml" "$@" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + set -e + if [ "$(classify "$rust_status")" != panic ] || \ + [ "$(classify "$ocaml_status")" != reject ]; then + printf '%s: expected Rust=panic/OCaml=reject, got Rust=%s/OCaml=%s\n' \ + "$name" "$rust_status" "$ocaml_status" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 + fi + if ! grep -F 'RESCRIPT_BSC_EXE points to missing path' \ + "$work/ocaml.err" >/dev/null; then + echo "$name: OCaml did not report the stale compiler path" >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 + fi + checked=$((checked + 1)) +} + +run_build_output_case() { + name=$1 + expected_status=$2 + fixture=$3 + mode=$4 + rust_project="$work/$name-rust" + ocaml_project="$work/$name-ocaml" + cp -R "$fixture" "$rust_project" + cp -R "$fixture" "$ocaml_project" + set +e + if [ "$mode" = quiet ]; then + "$rust" -q build "$rust_project" >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + "$ocaml" -q build "$ocaml_project" >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + elif [ "$mode" = forced-color ]; then + CLICOLOR_FORCE=1 "$rust" build "$rust_project" \ + >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + CLICOLOR_FORCE=1 "$ocaml" build "$ocaml_project" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + else + "$rust" build "$rust_project" >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + "$ocaml" build "$ocaml_project" >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + fi + set -e + normalize_project_path "$work/rust.out" "$work/rust.out.norm" "$rust_project" + normalize_project_path "$work/rust.err" "$work/rust.err.norm" "$rust_project" + normalize_project_path "$work/ocaml.out" "$work/ocaml.out.norm" "$ocaml_project" + normalize_project_path "$work/ocaml.err" "$work/ocaml.err.norm" "$ocaml_project" + if [ "$rust_status" -ne "$expected_status" ] || \ + [ "$ocaml_status" -ne "$expected_status" ] || \ + ! cmp -s "$work/rust.out.norm" "$work/ocaml.out.norm" || \ + ! cmp -s "$work/rust.err.norm" "$work/ocaml.err.norm"; then + echo "$name: $mode build output differs" >&2 + printf '%s\n' \ + "statuses: expected=$expected_status Rust=$rust_status OCaml=$ocaml_status" \ + >&2 + printf '%s\n' '--- Rust stdout ---' >&2 + cat "$work/rust.out.norm" >&2 + printf '%s\n' '--- OCaml stdout ---' >&2 + cat "$work/ocaml.out.norm" >&2 + printf '%s\n' '--- Rust stderr ---' >&2 + cat "$work/rust.err.norm" >&2 + printf '%s\n' '--- OCaml stderr ---' >&2 + cat "$work/ocaml.err.norm" >&2 + for stream in out err; do + if ! cmp -s "$work/rust.$stream.norm" "$work/ocaml.$stream.norm"; then + printf '%s\n' "--- $stream byte counts ---" >&2 + wc -c "$work/rust.$stream.norm" "$work/ocaml.$stream.norm" >&2 + printf '%s\n' "--- first differing $stream bytes ---" >&2 + cmp -l "$work/rust.$stream.norm" "$work/ocaml.$stream.norm" \ + | head -n 20 >&2 || true + printf '%s\n' "--- Rust $stream tail bytes ---" >&2 + tail -c 64 "$work/rust.$stream.norm" | od -An -tx1c >&2 + printf '%s\n' "--- OCaml $stream tail bytes ---" >&2 + tail -c 64 "$work/ocaml.$stream.norm" | od -An -tx1c >&2 + fi + done + exit 1 + fi + checked=$((checked + 1)) +} + +run_multiple_parse_errors_case() { + rust_project="$work/multiple-parse-errors-rust" + ocaml_project="$work/multiple-parse-errors-ocaml" + cp -R "$work/multiple-parse-errors" "$rust_project" + cp -R "$work/multiple-parse-errors" "$ocaml_project" + set +e + "$rust" build "$rust_project" >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + "$ocaml" build "$ocaml_project" >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + set -e + if [ "$rust_status" -ne 1 ] || [ "$ocaml_status" -ne 1 ]; then + printf 'multiple-parse-errors: expected status 1, got Rust=%s OCaml=%s\n' \ + "$rust_status" "$ocaml_status" >&2 + exit 1 + fi + for implementation in rust ocaml; do + error="$work/$implementation.err" + if [ "$(grep -cF 'Error in multiple-parse-errors:' "$error")" -ne 2 ] || \ + ! grep -E '[/\\]src[/\\]A\.res' "$error" >/dev/null || \ + ! grep -E '[/\\]src[/\\]B\.res' "$error" >/dev/null; then + echo "$implementation did not retain every independent parse error" >&2 + cat "$error" >&2 + exit 1 + fi + done + checked=$((checked + 1)) +} + +wait_for_file() { + path=$1 + attempts=0 + while [ "$attempts" -lt 150 ] && [ ! -f "$path" ]; do + attempts=$((attempts + 1)) + sleep 0.1 + done + if [ -f "$path" ]; then + return 0 + fi + printf 'Timed out waiting for file %s (requested at line %s)\n' \ + "$path" "${BASH_LINENO[0]:-unknown}" >&2 + return 1 +} + +wait_for_text() { + path=$1 + pattern=$2 + attempts=0 + while [ "$attempts" -lt 150 ] && \ + ! grep -F "$pattern" "$path" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + sleep 0.1 + done + if grep -F "$pattern" "$path" >/dev/null 2>&1; then + return 0 + fi + printf 'Timed out waiting for %s in %s\n' "$pattern" "$path" >&2 + if [ -f "$path" ]; then + printf '%s\n' '--- observed contents ---' >&2 + cat "$path" >&2 + else + printf '%s\n' '--- file does not exist ---' >&2 + fi + return 1 +} + +wait_for_exit() { + pid=$1 + attempts=0 + while [ "$attempts" -lt 150 ] && kill -0 "$pid" 2>/dev/null; do + attempts=$((attempts + 1)) + sleep 0.1 + done + ! kill -0 "$pid" 2>/dev/null +} + +wait_for_line_count() { + path=$1 + expected=$2 + attempts=0 + while [ "$attempts" -lt 150 ]; do + actual=0 + if [ -f "$path" ]; then + actual=$(wc -l <"$path" | tr -d ' ') + fi + if [ "$actual" -ge "$expected" ]; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 0.1 + done + return 1 +} + +line_count_stays() { + path=$1 + expected=$2 + attempts=0 + while [ "$attempts" -lt 20 ]; do + actual=0 + if [ -f "$path" ]; then + actual=$(wc -l <"$path" | tr -d ' ') + fi + if [ "$actual" -ne "$expected" ]; then + return 1 + fi + attempts=$((attempts + 1)) + sleep 0.1 + done +} + +run_build_output_case redirected-success 0 \ + "$root/rewatch-ocaml/tests/basic" redirected +run_build_output_case redirected-compile-error 1 \ + "$root/rewatch-ocaml/tests/failure" redirected +run_build_output_case redirected-parse-error 1 \ + "$work/redirected-parse-fixture" redirected +run_multiple_parse_errors_case +run_build_output_case redirected-warning 0 \ + "$root/rewatch-ocaml/tests/warning-replay" redirected +run_build_output_case redirected-config-diagnostics 0 \ + "$work/redirected-config-diagnostics" redirected +run_build_output_case forced-color-config-diagnostics 0 \ + "$work/redirected-config-diagnostics" forced-color + +run_build_output_case quiet-success 0 "$project" quiet +if [ -s "$work/rust.out" ] || [ -s "$work/rust.err" ] || \ + [ -s "$work/ocaml.out" ] || [ -s "$work/ocaml.err" ]; then + echo "quiet-success: a clean build emitted output" >&2 + for implementation in rust ocaml; do + printf '%s\n' "--- $implementation stdout ---" >&2 + cat "$work/$implementation.out" >&2 + printf '%s\n' "--- $implementation stderr ---" >&2 + cat "$work/$implementation.err" >&2 + done + exit 1 +fi +run_build_output_case quiet-compile-error 1 \ + "$root/rewatch-ocaml/tests/failure" quiet +run_build_output_case quiet-parse-error 1 "$work/redirected-parse-fixture" quiet +run_build_output_case quiet-warning 0 \ + "$root/rewatch-ocaml/tests/warning-replay" quiet + +run_case build-subcommand-version exit2 accept build --version +run_case clustered-global-version accept accept -vV build +run_case clustered-global-verbosity accept accept -vvvvv build "$project" +run_case clustered-global-version-before-help accept accept -Vh build +run_case clustered-global-help-before-version accept accept -hV build +run_case clustered-subcommand-help-before-version accept accept build -hV +run_case clustered-subcommand-version-before-help exit2 accept build -Vh +"$ocaml" build --help=groff >"$work/ocaml-help-groff.out" +if ! grep -F '.\" Pipe this output to groff' \ + "$work/ocaml-help-groff.out" >/dev/null; then + echo "formatted-help: OCaml did not preserve the requested groff format" >&2 + cat "$work/ocaml-help-groff.out" >&2 + exit 1 +fi +checked=$((checked + 1)) +run_case implicit-conflicting-verbosity exit2 exit2 -v -q +run_case build-conflicting-verbosity exit2 exit2 build --verbose --quiet +run_case filter-perl-quoting exit2 exit2 build --filter '\QFoo.res\E' "$project" +run_case filter-comment-group exit2 exit2 build --filter '(?#note)Foo' "$project" +run_case filter-octal-escape exit2 exit2 build --filter '\123' "$project" +run_case filter-braced-octal-escape exit2 exit2 build --filter '\o{123}' "$project" +run_case filter-perl-control-escape-e exit2 exit2 build --filter '\e' "$project" +run_case filter-perl-anchor-g exit2 exit2 build --filter '\G' "$project" +run_case filter-perl-anchor-z exit2 exit2 build --filter '\Z' "$project" +run_case filter-descending-range exit2 exit2 build --filter '[z-a]' "$project" +run_case filter-escaped-range-start exit2 exit2 build --filter '[\d-z]' "$project" +run_case filter-in-class-nonword accept exit2 build --filter \ + '^[\W]+\.res$' "$project" +run_case filter-collating-element accept exit2 build --filter '[[.a.]]' "$project" +run_case filter-class-algebra accept exit2 build --filter \ + '[a-z&&[^aeiou]]' "$project" +run_case build-no-timing-preserves-folder exit2 accept build --no-timing "$project" +run_case compiler-args-source accept accept compiler-args "$project/src/A.res" +run_case compiler-args-extension accept reject compiler-args "$project/src/A.txt" +run_case compiler-args-missing panic reject compiler-args "$project/src/Missing.res" +run_case compiler-args-no-project panic reject compiler-args "$work/orphan/A.res" + +run_missing_bsc_case build-missing-bsc build "$project" +run_missing_bsc_case format-missing-bsc format "$project/src/A.res" +set +e +RESCRIPT_BSC_EXE="$work/missing-bsc" \ + "$rust" clean "$work/clean-missing-bsc" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +RESCRIPT_BSC_EXE="$work/missing-bsc" \ + "$ocaml" clean "$work/clean-missing-bsc" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$(classify "$rust_status")" != panic ] || [ "$ocaml_status" -ne 0 ] || \ + [ -e "$work/clean-missing-bsc/lib/bs/marker" ]; then + echo "clean-missing-bsc: expected Rust panic and successful OCaml cleanup" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +set +e +env -u RESCRIPT_RUNTIME "$rust" clean "$work/clean-missing-runtime" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +env -u RESCRIPT_RUNTIME "$ocaml" clean "$work/clean-missing-runtime" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$(classify "$rust_status")" != reject ] || [ "$ocaml_status" -ne 0 ] || \ + [ -e "$work/clean-missing-runtime/lib/bs/marker" ]; then + echo "clean-missing-runtime: expected Rust rejection and successful OCaml cleanup" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +set +e +env -u RESCRIPT_RUNTIME "$rust" build "$work/missing-runtime-package" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +env -u RESCRIPT_RUNTIME "$ocaml" build "$work/missing-runtime-package" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$rust_status" -eq 0 ] || [ "$ocaml_status" -eq 0 ]; then + echo "build-missing-runtime-package: expected both builds to reject" >&2 + exit 1 +fi +require_both_errors_contain build-missing-runtime-package \ + 'The rescript runtime package could not be found.' +require_both_errors_contain build-missing-runtime-package \ + 'Please set RESCRIPT_RUNTIME environment variable' +checked=$((checked + 1)) +set +e +RESCRIPT_RUNTIME="$work/missing-runtime" "$rust" build "$project" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +RESCRIPT_RUNTIME="$work/missing-runtime" "$ocaml" build "$project" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$rust_status" -eq 0 ] || [ "$ocaml_status" -eq 0 ] || \ + ! grep -F "RESCRIPT_RUNTIME points to missing path" \ + "$work/ocaml.err" >/dev/null || \ + ! grep -F "missing-runtime" "$work/ocaml.err" >/dev/null || \ + ! grep -F "The module or file Pervasives can't be found." \ + "$work/rust.err" >/dev/null; then + echo "build-stale-runtime: expected contextual OCaml preflight rejection" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +run_case format-missing-file reject reject format "$project/src/Missing.res" +require_same_output format-missing-file +run_case format-directory reject reject format "$project/src" +require_same_output format-directory +run_case format-unsupported-extension reject reject format "$project/src/A.txt" +require_same_output format-unsupported-extension +run_cwd_case format-no-config reject reject "$work/empty" format +require_both_errors_contain format-no-config \ + "Could not read rescript.json at $work/empty:" +require_both_errors_contain format-no-config "$work/empty/bsconfig.json" +run_cwd_case format-malformed-config reject reject "$work/malformed" format +require_both_errors_contain format-malformed-config \ + "Could not read rescript.json at $work/malformed:" +if ! grep -F 'Failed to parse rescript.json' "$work/rust.err" >/dev/null || \ + ! grep -F 'invalid JSON' "$work/ocaml.err" >/dev/null; then + echo "format-malformed-config: JSON parser context was lost" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_cwd_case format-config-directory reject reject "$work/config-directory" format +require_both_errors_contain format-config-directory \ + "$work/config-directory/rescript.json" +if ! grep -E 'Is a directory|Access is denied' "$work/rust.err" >/dev/null || \ + ! grep -F 'Is a directory' "$work/ocaml.err" >/dev/null; then + echo "format-config-directory: file-kind diagnostic was lost" >&2 + exit 1 +fi +set +e +printf 'let value =\n' | "$rust" format --stdin .res \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +printf 'let value =\n' | "$ocaml" format --stdin .res \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$rust_status" -eq 0 ] || [ "$ocaml_status" -eq 0 ] || \ + [ -s "$work/rust.out" ] || [ -s "$work/ocaml.out" ]; then + echo "format-invalid-stdin: expected both formatters to reject" >&2 + exit 1 +fi +require_both_errors_contain format-invalid-stdin 'Error formatting stdin:' +require_both_errors_contain format-invalid-stdin \ + 'This let-binding misses an expression' +checked=$((checked + 1)) +mkdir -p "$work/format-write-rust" "$work/format-write-ocaml" +printf 'let value=1\n' >"$work/format-write-rust/A.res" +printf 'let value=1\n' >"$work/format-write-ocaml/A.res" +chmod 0444 "$work/format-write-rust/A.res" "$work/format-write-ocaml/A.res" +if [ ! -w "$work/format-write-rust/A.res" ] && \ + [ ! -w "$work/format-write-ocaml/A.res" ]; then + set +e + "$rust" format "$work/format-write-rust/A.res" \ + >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + "$ocaml" format "$work/format-write-ocaml/A.res" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + set -e + if [ "$rust_status" -eq 0 ] || [ "$ocaml_status" -eq 0 ] || \ + ! grep -E 'Permission denied|Access is denied' "$work/rust.err" >/dev/null || \ + ! grep -F "Could not write formatted file" "$work/ocaml.err" >/dev/null || \ + ! grep -F "format-write-ocaml/A.res" \ + < <(tr '\\' '/' <"$work/ocaml.err") >/dev/null; then + echo "format-write-failure: formatter write failures lost context" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 + fi + checked=$((checked + 1)) +fi +chmod 0644 "$work/format-write-rust/A.res" "$work/format-write-ocaml/A.res" + +run_case build-missing-folder reject reject build "$work/missing" +run_case build-existing-folder-without-config reject reject build "$work/empty" +require_both_errors_contain build-existing-folder-without-config "$work/empty" +run_case build-malformed-config reject reject build "$work/malformed" +require_both_errors_contain build-malformed-config \ + "$work/malformed" +run_case build-malformed-parent reject reject build "$work/malformed-parent/child" +require_both_errors_contain build-malformed-parent \ + "$work/malformed-parent" +run_case build-config-path-is-directory reject reject build "$work/config-directory" +require_both_errors_contain build-config-path-is-directory \ + "$work/config-directory" +run_case after-build-nonzero-is-not-ignored accept reject build --after-build \ + "node $command_work/failing-after-build.js" "$project" +if ! grep -F 'hook failed' "$work/rust.err" >/dev/null || \ + ! grep -F -- '--after-build command failed with exit code 7' \ + "$work/ocaml.err" >/dev/null || \ + ! grep -F 'hook failed' "$work/ocaml.err" >/dev/null; then + echo "Nonzero --after-build handling differs from its recorded outcomes" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_case after-build-empty panic reject build --after-build '' "$project" +if ! grep -F -- '--after-build command cannot be empty' \ + "$work/ocaml.err" >/dev/null; then + echo "Empty --after-build did not produce a contextual OCaml error" >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_case after-build-missing-program panic reject build --after-build \ + rewatch-command-that-does-not-exist "$project" +if ! grep -F 'Could not run --after-build command' \ + "$work/ocaml.err" >/dev/null || \ + ! grep -F 'rewatch-command-that-does-not-exist' \ + "$work/ocaml.err" >/dev/null; then + echo "Missing --after-build program did not produce a contextual OCaml error" >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_case js-post-build-nonzero reject reject build "$work/failed-js-post-build" +js_post_build_error="js-post-build command failed for $work/failed-js-post-build/src/A.js" +require_both_errors_contain js-post-build-nonzero "$js_post_build_error" +printf 'not-a-pid' >"$work/malformed-lock/lib/build.lock" +run_case build-malformed-lock reject reject build "$work/malformed-lock" +if [ "$(cat "$work/malformed-lock/lib/build.lock")" != not-a-pid ]; then + echo "OCaml replaced a malformed build lock with unknown ownership" >&2 + exit 1 +fi +if [ -n "$(find "$work/malformed-lock/lib" -maxdepth 1 \ + -name '.build-lock-*.tmp' -print -quit)" ]; then + echo "OCaml left a build-lock candidate after acquisition failed" >&2 + exit 1 +fi +# Use the real executable as the lock owner. Copying an MSYS utility to a +# rescript-prefixed name does not change the image name reported by tasklist, +# while both implementations deliberately reject locks owned by unrelated +# executables to protect against PID reuse. +"$ocaml" watch "$work/signal-lock-owner" \ + >"$work/signal-lock-owner.out" 2>"$work/signal-lock-owner.err" & +signal_lock_owner_pid=$! +background_pids="$background_pids $signal_lock_owner_pid" +wait_for_text "$work/signal-lock-owner.out" "Finished initial compilation" +printf '%s' "$(lock_owner_pid "$signal_lock_owner_pid")" \ + >"$work/signal-lock/lib/build.lock" +"$ocaml" watch "$work/signal-lock" \ + >"$work/signal-lock.out" 2>"$work/signal-lock.err" & +signal_lock_watch_pid=$! +background_pids="$background_pids $signal_lock_watch_pid" +wait_for_text "$work/signal-lock.out" "Waiting for other build to finish" +kill -TERM "$signal_lock_watch_pid" +set +e +wait "$signal_lock_watch_pid" +signal_lock_status=$? +set -e +if ! $windows_posix_shell && [ "$signal_lock_status" -ne 0 ]; then + echo "Signal during build-lock wait exited with $signal_lock_status" >&2 + exit 1 +fi +# MSYS implements kill for native processes by terminating them externally, +# so no Windows console control event reaches OCaml and the status is nonzero. +# The native Windows unit tests cover deferred signal dispatch; this integration +# case still proves that terminating a waiter cannot emit a spurious diagnostic. +if [ -s "$work/signal-lock.err" ]; then + echo "Signal during build-lock wait emitted a diagnostic" >&2 + cat "$work/signal-lock.err" >&2 + exit 1 +fi +kill -TERM "$signal_lock_owner_pid" +wait "$signal_lock_owner_pid" 2>/dev/null || true +checked=$((checked + 1)) +printf 'not-a-pid' >"$work/malformed-lock/lib/watch.lock" +run_case watch-malformed-lock reject reject watch "$work/malformed-lock" +if [ "$(cat "$work/malformed-lock/lib/watch.lock")" != not-a-pid ]; then + echo "OCaml replaced a malformed watch lock with unknown ownership" >&2 + exit 1 +fi +if [ -n "$(find "$work/malformed-lock/lib" -maxdepth 1 \ + -name '.watch-lock-*.tmp' -print -quit)" ]; then + echo "OCaml left a watch-lock candidate after acquisition failed" >&2 + exit 1 +fi +printf '{ invalid json\n' >"$work/watch-lock-order/rescript.json" +# A copied MSYS utility keeps its original Windows image name and is therefore +# rejected as an unrelated lock owner. Run the implementation under test as a +# real watcher so both process identity and the native PID match production. +for implementation in rust ocaml; do + case "$implementation" in + rust) executable=$rust ;; + ocaml) executable=$ocaml ;; + esac + owner_dir="$work/watch-lock-owner-$implementation" + mkdir -p "$owner_dir/src" + printf '{"name":"watch-lock-owner-%s","sources":["src"]}\n' \ + "$implementation" >"$owner_dir/rescript.json" + printf 'let value = 1\n' >"$owner_dir/src/A.res" + "$executable" watch "$owner_dir" \ + >"$owner_dir/watch.out" 2>"$owner_dir/watch.err" & + watch_lock_owner_pid=$! + background_pids="$background_pids $watch_lock_owner_pid" + wait_for_text "$owner_dir/watch.out" "Finished initial compilation" + native_owner_pid=$(lock_owner_pid "$watch_lock_owner_pid") + if [ -z "$native_owner_pid" ]; then + echo "watch-lock-before-config: could not resolve $implementation owner PID" >&2 + exit 1 + fi + printf '%s' "$native_owner_pid" \ + >"$work/watch-lock-order/lib/watch.lock" + set +e + "$executable" watch "$work/watch-lock-order" \ + >"$work/$implementation.out" 2>"$work/$implementation.err" + status=$? + set -e + if [ "$(classify "$status")" != reject ] || \ + ! grep -F 'A ReScript build is already running' \ + "$work/$implementation.err" >/dev/null || \ + grep -F 'invalid JSON' "$work/$implementation.err" >/dev/null; then + echo "watch-lock-before-config: $implementation lock acquisition did not precede config parsing" >&2 + cat "$work/$implementation.out" "$work/$implementation.err" >&2 + exit 1 + fi + kill -TERM "$watch_lock_owner_pid" + wait "$watch_lock_owner_pid" 2>/dev/null || true +done +checked=$((checked + 1)) +run_case build-interface-path-mismatch reject reject build \ + "$work/interface-mismatch" +if ! cmp -s "$work/rust.err" "$work/ocaml.err"; then + echo "Implementation/interface mismatch diagnostics differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.err" >&2 + exit 1 +fi +set +e +"$rust" build "$work/exotic-module-rust" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +"$ocaml" build "$work/exotic-module-ocaml" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +rust_mlmap="$work/exotic-module-rust/lib/bs/Ns.mlmap" +ocaml_mlmap="$work/exotic-module-ocaml/lib/bs/Ns.mlmap" +if [ "$(classify "$rust_status")" != accept ] || \ + [ "$(classify "$ocaml_status")" != accept ] || \ + ! cmp -s "$rust_mlmap" "$ocaml_mlmap" || \ + ! grep -Fx Main "$ocaml_mlmap" >/dev/null || \ + grep -F 'Foo-bar' "$ocaml_mlmap" >/dev/null; then + printf 'namespace-exotic-module: expected matching successful builds, got Rust=%s/OCaml=%s\n' \ + "$rust_status" "$ocaml_status" >&2 + printf '%s\n' '--- Rust output / namespace map ---' >&2 + cat "$work/rust.out" "$work/rust.err" "$rust_mlmap" >&2 + printf '%s\n' '--- OCaml output / namespace map ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" "$ocaml_mlmap" >&2 + exit 1 +fi +checked=$((checked + 1)) +set +e +"$rust" build --filter nested "$work/filter-basename-rust" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +"$ocaml" build --filter nested "$work/filter-basename-ocaml" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$(classify "$rust_status")" != accept ] || \ + [ "$(classify "$ocaml_status")" != accept ] || \ + [ -e "$work/filter-basename-rust/src/nested/A.js" ] || \ + [ -e "$work/filter-basename-ocaml/src/nested/A.js" ]; then + echo "Source filters did not consistently ignore directory-only matches" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +set +e +"$rust" build --filter 'A\.res$' "$work/filter-basename-rust" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +"$ocaml" build --filter 'A\.res$' "$work/filter-basename-ocaml" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$(classify "$rust_status")" != accept ] || \ + [ "$(classify "$ocaml_status")" != accept ] || \ + [ ! -e "$work/filter-basename-rust/src/nested/A.js" ] || \ + [ ! -e "$work/filter-basename-ocaml/src/nested/A.js" ]; then + echo "Source filters did not consistently include a basename match" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +set +e +"$rust" build --filter '(?:A|B\d)\.res$' "$work/filter-basename-rust" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +"$ocaml" build --filter '(?:A|B\d)\.res$' "$work/filter-basename-ocaml" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$(classify "$rust_status")" != accept ] || \ + [ "$(classify "$ocaml_status")" != accept ] || \ + [ ! -e "$work/filter-basename-rust/src/nested/B2.js" ] || \ + [ ! -e "$work/filter-basename-ocaml/src/nested/B2.js" ]; then + echo "Source filters did not consistently support Rust-style regex syntax" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +run_case build-excludes-external-dev-source accept accept build \ + "$work/external-dev-source" +run_case build-ignores-dormant-external-dev-permission reject accept build \ + "$work/external-dev-permission" +strip_ansi "$work/rust.out" >"$work/rust.out.plain" +strip_ansi "$work/rust.err" >"$work/rust.err.plain" +if ! grep -F 'a has the following unallowed dependencies' \ + "$work/rust.err.plain" >/dev/null; then + echo "Rust dormant external dev-dependency rejection was not reproduced" >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + exit 1 +fi +run_case build-reports-all-active-permission-failures reject reject build \ + "$work/active-permission" +strip_ansi "$work/rust.out" >"$work/rust.out.plain" +strip_ansi "$work/rust.err" >"$work/rust.err.plain" +strip_ansi "$work/ocaml.out" >"$work/ocaml.out.plain" +strip_ansi "$work/ocaml.err" >"$work/ocaml.err.plain" +rust_permission_details=$(grep -Ec '^dependencies dependencies: (a|b)$' \ + "$work/rust.out.plain" || true) +if [ "$rust_permission_details" -ne 1 ] || \ + ! grep -Fx 'root dependencies: a' "$work/ocaml.err.plain" >/dev/null || \ + ! grep -Fx 'root dependencies: b' "$work/ocaml.err.plain" >/dev/null || \ + ! grep -F 'unallowed_dependents' "$work/rust.err.plain" >/dev/null || \ + ! grep -F 'config.json' "$work/rust.err.plain" >/dev/null || \ + ! grep -F 'Update allowed-dependents in the dependency rescript.json files.' \ + "$work/ocaml.err.plain" >/dev/null || [ -s "$work/ocaml.out.plain" ]; then + echo "Active dependency-permission diagnostics changed unexpectedly" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_case build-source-path-is-file accept accept build "$work/source-path-file" +if ! cmp -s "$work/rust.err" "$work/ocaml.err" || \ + ! grep -F 'Could not read folder: "src"' "$work/ocaml.err" >/dev/null; then + echo "Non-directory source diagnostics differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_case build-missing-source-folder accept accept build \ + "$work/missing-source-folder" +if ! cmp -s "$work/rust.err" "$work/ocaml.err"; then + echo "Missing source-folder diagnostics differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.err" >&2 + exit 1 +fi +run_case build-dependency-without-sources accept accept build \ + "$work/dependency-without-sources" +if ! cmp -s "$work/rust.err" "$work/ocaml.err"; then + echo "Dependency-without-sources diagnostics differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.err" >&2 + exit 1 +fi +run_case build-default-feature-cycle accept accept build \ + "$work/default-feature-cycle" +run_cwd_case format-requested-feature-cycle reject reject \ + "$work/format-feature-cycle" format +if ! cmp -s "$work/rust.err" "$work/ocaml.err"; then + echo "Requested format feature-cycle diagnostics differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_cwd_case format-scans-graph-with-effective-features \ + reject reject \ + "$work/format-source-selection" format --check +normalize_project_path "$work/rust.err" "$work/rust.err.norm" \ + "$work/format-source-selection" +normalize_project_path "$work/ocaml.err" "$work/ocaml.err.norm" \ + "$work/format-source-selection" +missing_folder='Could not read folder: "missing". Specified in dependency: installed' +base_file='[format check] /packages/local/base/Base.res' +native_file='[format check] /packages/local/native/Native.res' +other_file='/packages/local/other/Other.res' +if ! grep -F "$missing_folder" "$work/rust.err.norm" >/dev/null || \ + ! grep -F "$missing_folder" "$work/ocaml.err.norm" >/dev/null || \ + ! grep -F "$base_file" "$work/rust.err.norm" >/dev/null || \ + ! grep -F "$base_file" "$work/ocaml.err.norm" >/dev/null || \ + ! grep -F "$native_file" "$work/rust.err.norm" >/dev/null || \ + ! grep -F "$native_file" "$work/ocaml.err.norm" >/dev/null || \ + grep -F "$other_file" "$work/rust.err.norm" >/dev/null || \ + grep -F "$other_file" "$work/ocaml.err.norm" >/dev/null || \ + ! grep -F 'The 2 files listed above need formatting' "$work/rust.err.norm" >/dev/null || \ + ! grep -F 'The 2 files listed above need formatting' "$work/ocaml.err.norm" >/dev/null; then + echo "Implicit format package scanning or feature selection differs" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_case clean-dependency-without-sources accept accept clean \ + "$work/dependency-without-sources" +if ! cmp -s "$work/rust.err" "$work/ocaml.err"; then + echo "Clean dependency-without-sources diagnostics differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.err" >&2 + exit 1 +fi +set +e +"$rust" clean "$project" >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +"$ocaml" clean "$project" >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$rust_status" -ne 0 ] || [ "$ocaml_status" -ne 0 ] || \ + ! cmp -s "$work/rust.out" "$work/ocaml.out" || \ + ! cmp -s "$work/rust.err" "$work/ocaml.err" || \ + ! grep -Fx 'Cleaning command-validation' "$work/ocaml.out" >/dev/null; then + echo "Redirected clean progress differs" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +set +e +"$rust" -q clean "$project" >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +"$ocaml" -q clean "$project" >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$rust_status" -ne 0 ] || [ "$ocaml_status" -ne 0 ] || \ + [ -s "$work/rust.out" ] || [ -s "$work/rust.err" ] || \ + [ -s "$work/ocaml.out" ] || [ -s "$work/ocaml.err" ]; then + echo "Quiet redirected clean emitted output" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +run_cwd_case format-dependency-without-sources accept accept \ + "$work/dependency-without-sources" format +if ! cmp -s "$work/rust.err" "$work/ocaml.err"; then + echo "Format dependency-without-sources diagnostics differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_case build-package-name-mismatch accept accept build \ + "$work/package-name-mismatch" +normalize_project_path "$work/rust.err" "$work/rust.err.norm" \ + "$work/package-name-mismatch" +normalize_project_path "$work/ocaml.err" "$work/ocaml.err.norm" \ + "$work/package-name-mismatch" +if ! cmp -s "$work/rust.err.norm" "$work/ocaml.err.norm"; then + echo "Package-name mismatch diagnostics differ" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.err" >&2 + exit 1 +fi +run_case build-malformed-package-json reject reject build \ + "$work/malformed-package-json" +require_both_errors_contain build-malformed-package-json \ + 'Could not initialize build: Could not parse package.json:' +run_case build-mismatched-dependency-name panic exit2 build \ + "$work/mismatched-dependency" +strip_ansi "$work/ocaml.err" >"$work/ocaml.err.plain" +if ! grep -F \ + "resolved package identity 'different-name' does not match the requested dependency name" \ + "$work/ocaml.err.plain" >/dev/null; then + echo "build-mismatched-dependency-name: OCaml did not reject the conflicting identity" >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_case build-missing-dependency exit2 exit2 build "$work/missing-dependency" +require_both_errors_contain build-missing-dependency \ + "Could not build package tree reading dependency 'absent' at path '$work/missing-dependency'. Error:" +run_case build-configless-dependency exit2 exit2 build "$work/configless-dependency" +require_both_errors_contain build-configless-dependency \ + "Could not build package tree for 'no-config' at path '$work/configless-dependency'. Error:" +run_case build-malformed-dependency exit2 exit2 build "$work/malformed-dependency" +require_both_errors_contain build-malformed-dependency \ + "Could not build package tree for 'bad-config' at path '$work/malformed-dependency'. Error:" +run_case build-duplicate-dependency accept accept build "$work/duplicate-dependency" +duplicate_warning='Duplicated package: shared ./node_modules/shared (chosen) vs ./node_modules/a/node_modules/shared in ./node_modules/a' +normalize_project_path "$work/rust.err" "$work/rust.err.norm" \ + "$work/duplicate-dependency" +normalize_project_path "$work/ocaml.err" "$work/ocaml.err.norm" \ + "$work/duplicate-dependency" +if ! grep -F "$duplicate_warning" "$work/rust.err.norm" >/dev/null || \ + ! grep -F "$duplicate_warning" "$work/ocaml.err.norm" >/dev/null; then + echo "Duplicate dependency warning was not emitted by both implementations" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +run_cwd_case format-duplicate-dependency accept accept \ + "$work/duplicate-dependency" format +normalize_project_path "$work/rust.err" "$work/rust.err.norm" \ + "$work/duplicate-dependency" +normalize_project_path "$work/ocaml.err" "$work/ocaml.err.norm" \ + "$work/duplicate-dependency" +if ! grep -F "$duplicate_warning" "$work/rust.err.norm" >/dev/null || \ + ! grep -F "$duplicate_warning" "$work/ocaml.err.norm" >/dev/null; then + echo "Format duplicate dependency warning was not emitted by both implementations" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi + +set +e +REWATCH_REAL_BSC="$RESCRIPT_BSC_EXE" \ +REWATCH_BSC_PROXY_MODE=delete-source \ +REWATCH_SOURCE_TO_DELETE="$(command_path "$work/publication-race-rust/src/A.res")" \ +REWATCH_SOURCE_DELETED="$(command_path "$work/publication-race-rust/source-deleted")" \ +RESCRIPT_BSC_EXE="$delete_source_bsc" \ + "$rust" build "$work/publication-race-rust" \ + >"$work/rust.out" 2>"$work/rust.err" & +rust_pid=$! +attempts=0 +while kill -0 "$rust_pid" 2>/dev/null && [ "$attempts" -lt 150 ]; do + attempts=$((attempts + 1)) + sleep 0.1 +done +if kill -0 "$rust_pid" 2>/dev/null; then + kill -TERM "$rust_pid" 2>/dev/null + wait "$rust_pid" 2>/dev/null + rust_status=124 +else + wait "$rust_pid" + rust_status=$? +fi +REWATCH_REAL_BSC="$RESCRIPT_BSC_EXE" \ +REWATCH_BSC_PROXY_MODE=delete-source \ +REWATCH_SOURCE_TO_DELETE="$(command_path "$work/publication-race-ocaml/src/A.res")" \ +REWATCH_SOURCE_DELETED="$(command_path "$work/publication-race-ocaml/source-deleted")" \ +RESCRIPT_BSC_EXE="$delete_source_bsc" \ + "$ocaml" build "$work/publication-race-ocaml" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$rust_status" -ne 124 ] || \ + ! grep -F "copying source file failed" "$work/rust.err" >/dev/null || \ + [ "$(classify "$ocaml_status")" != reject ] || \ + ! grep -F "A.res" "$work/ocaml.err" >/dev/null; then + printf 'build-source-disappears-during-publication: expected Rust=worker-panic/timeout and OCaml=path-bearing rejection, got Rust=%s/OCaml=%s\n' \ + "$rust_status" "$ocaml_status" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) + +set +e +RAYON_NUM_THREADS=1 \ +REWATCH_REAL_BSC="$RESCRIPT_BSC_EXE" \ +REWATCH_BSC_PROXY_MODE=delete-parse-sources \ +REWATCH_SOURCE_A="$(command_path "$work/parse-source-race-rust/src/A.res")" \ +REWATCH_SOURCE_B="$(command_path "$work/parse-source-race-rust/src/B.res")" \ +REWATCH_SOURCES_DELETED="$(command_path "$work/parse-source-race-rust/sources-deleted")" \ +RESCRIPT_BSC_EXE="$delete_parse_sources_bsc" \ + "$rust" build "$work/parse-source-race-rust" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +REWATCH_REAL_BSC="$RESCRIPT_BSC_EXE" \ +REWATCH_BSC_PROXY_MODE=delete-parse-sources \ +REWATCH_SOURCE_A="$(command_path "$work/parse-source-race-ocaml/src/A.res")" \ +REWATCH_SOURCE_B="$(command_path "$work/parse-source-race-ocaml/src/B.res")" \ +REWATCH_SOURCES_DELETED="$(command_path "$work/parse-source-race-ocaml/sources-deleted")" \ +RESCRIPT_BSC_EXE="$delete_parse_sources_bsc" \ + "$ocaml" build "$work/parse-source-race-ocaml" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$rust_status" -ne 101 ] || \ + ! grep -F "file not found" "$work/rust.err" >/dev/null || \ + [ "$(classify "$ocaml_status")" != reject ] || \ + ! grep -F "parse-source-race" "$work/ocaml.err" >/dev/null || \ + ! grep -E 'A\.(res|ast)' "$work/ocaml.err" >/dev/null; then + printf 'build-source-disappears-before-parse-read: expected Rust=panic and OCaml=path-bearing rejection, got Rust=%s/OCaml=%s\n' \ + "$rust_status" "$ocaml_status" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) + +set +e +REWATCH_REAL_BSC="$RESCRIPT_BSC_EXE" \ +REWATCH_BSC_PROXY_MODE=delete-ast \ +REWATCH_AST_DELETED="$(command_path "$work/ast-race-rust/ast-deleted")" \ +RESCRIPT_BSC_EXE="$delete_ast_bsc" \ + "$rust" build "$work/ast-race-rust" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +REWATCH_REAL_BSC="$RESCRIPT_BSC_EXE" \ +REWATCH_BSC_PROXY_MODE=delete-ast \ +REWATCH_AST_DELETED="$(command_path "$work/ast-race-ocaml/ast-deleted")" \ +RESCRIPT_BSC_EXE="$delete_ast_bsc" \ + "$ocaml" build "$work/ast-race-ocaml" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$rust_status" -ne 101 ] || \ + ! grep -F "Could not read file" "$work/rust.err" >/dev/null || \ + [ "$(classify "$ocaml_status")" != reject ] || \ + ! grep -F "A.ast" "$work/ocaml.err" >/dev/null; then + printf 'build-ast-disappears-before-dependency-read: expected Rust=panic and OCaml=path-bearing rejection, got Rust=%s/OCaml=%s\n' \ + "$rust_status" "$ocaml_status" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) + +"$rust" watch "$work/watch-config-rust" \ + >"$work/watch-rust.out" 2>"$work/watch-rust.err" & +rust_watch_pid=$! +background_pids="$background_pids $rust_watch_pid" +wait_for_file "$work/watch-config-rust/src/A.js" +wait_for_text "$work/watch-config-rust/lib/ocaml/.compiler.log" "#Done(" +printf '{ invalid json\n' >"$work/watch-config-rust/rescript.json" +wait_for_text "$work/watch-rust.err" "Could not initialize build" +wait_for_exit "$rust_watch_pid" +set +e +wait "$rust_watch_pid" +rust_watch_status=$? +set -e +if [ "$rust_watch_status" -ne 101 ]; then + printf 'watch-invalid-config-rebuild: expected Rust panic exit 101, got %s\n' \ + "$rust_watch_status" >&2 + cat "$work/watch-rust.out" "$work/watch-rust.err" >&2 + exit 1 +fi + +"$ocaml" watch "$work/watch-config-ocaml" \ + >"$work/watch-ocaml.out" 2>"$work/watch-ocaml.err" & +ocaml_watch_pid=$! +background_pids="$background_pids $ocaml_watch_pid" +wait_for_file "$work/watch-config-ocaml/src/A.js" +wait_for_text "$work/watch-config-ocaml/lib/ocaml/.compiler.log" "#Done(" +printf '{ invalid json\n' >"$work/watch-config-ocaml/rescript.json" +wait_for_text "$work/watch-ocaml.err" "invalid JSON" +if ! kill -0 "$ocaml_watch_pid" 2>/dev/null; then + echo "OCaml watcher exited after a recoverable config error" >&2 + cat "$work/watch-ocaml.out" "$work/watch-ocaml.err" >&2 + exit 1 +fi +printf '{"name":"watch-config","sources":["src"],"dependencies":["definitely-missing-dep"]}\n' \ + >"$work/watch-config-ocaml/rescript.json" +wait_for_text "$work/watch-ocaml.err" "definitely-missing-dep" +if ! kill -0 "$ocaml_watch_pid" 2>/dev/null; then + echo "OCaml watcher exited after a recoverable dependency error" >&2 + cat "$work/watch-ocaml.out" "$work/watch-ocaml.err" >&2 + exit 1 +fi +printf '{"name":"watch-config","sources":["src"],"package-specs":{"module":"esmodule","in-source":true,"suffix":".mjs"}}\n' \ + >"$work/watch-config-ocaml/rescript.json" +wait_for_file "$work/watch-config-ocaml/src/A.mjs" +wait_for_file "$work/watch-config-ocaml/lib/bs/build.ninja" +if ! terminate_and_wait "$ocaml_watch_pid" "recoverable-config watcher"; then + cat "$work/watch-ocaml.out" "$work/watch-ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) + +retained_graph_marker="$work/watch-retained-graph/after-build.log" +REWATCH_WATCH_FILTER_MARKER="$retained_graph_marker" \ + "$ocaml" watch --after-build "node $command_work/watch-filter-marker.js" \ + "$work/watch-retained-graph" \ + >"$work/watch-retained-graph.out" \ + 2>"$work/watch-retained-graph.err" & +retained_graph_pid=$! +background_pids="$background_pids $retained_graph_pid" +wait_for_line_count "$retained_graph_marker" 1 +printf 'let newValue = 2\n' >"$work/watch-retained-graph/src/C.res" +printf 'let value = C.newValue\n' >"$work/watch-retained-graph/src/B.res" +wait_for_line_count "$retained_graph_marker" 2 +wait_for_text "$work/watch-retained-graph/src/B.js" "C.newValue" +printf 'let value = C.newValue\n' >"$work/watch-retained-graph/src/B.res" +printf 'let newValue = B.value\n' >"$work/watch-retained-graph/src/C.res" +wait_for_text "$work/watch-retained-graph.err" \ + "Found a circular dependency in your code" +if ! kill -0 "$retained_graph_pid" 2>/dev/null; then + echo "OCaml watcher exited after an incremental dependency cycle" >&2 + exit 1 +fi +printf 'let newValue = 3\n' >"$work/watch-retained-graph/src/C.res" +wait_for_line_count "$retained_graph_marker" 3 +terminate_and_wait "$retained_graph_pid" "retained-graph watcher" +checked=$((checked + 1)) + +"$ocaml" watch "$work/watch-dependency-recovery" \ + >"$work/watch-dependency-recovery.out" \ + 2>"$work/watch-dependency-recovery.err" & +dependency_recovery_pid=$! +background_pids="$background_pids $dependency_recovery_pid" +wait_for_text "$work/watch-dependency-recovery.err" \ + "Could not build package tree for 'dep'" +if ! kill -0 "$dependency_recovery_pid" 2>/dev/null; then + echo "OCaml watcher exited after a malformed dependency config" >&2 + exit 1 +fi +printf '{"name":"dep","sources":["src"]}\n' \ + >"$work/watch-dependency-recovery/packages/dep/rescript.json" +wait_for_file "$work/watch-dependency-recovery/src/A.js" +terminate_and_wait "$dependency_recovery_pid" "dependency-recovery watcher" +checked=$((checked + 1)) + +"$ocaml" watch "$work/watch-dependency-install" \ + >"$work/watch-dependency-install.out" \ + 2>"$work/watch-dependency-install.err" & +dependency_install_pid=$! +background_pids="$background_pids $dependency_install_pid" +if ! wait_for_text "$work/watch-dependency-install.err" \ + "Could not resolve dependency @scope/dep"; then + printf '%s\n' '--- watcher stdout ---' >&2 + cat "$work/watch-dependency-install.out" >&2 + exit 1 +fi +if ! kill -0 "$dependency_install_pid" 2>/dev/null; then + echo "OCaml watcher exited while waiting for a missing dependency" >&2 + exit 1 +fi +mkdir "$work/watch-dependency-install/node_modules/@scope" +sleep 1 +mkdir -p "$work/watch-dependency-install/node_modules/@scope/dep/src" +printf '{"name":"@scope/dep","sources":["src"]}\n' \ + >"$work/watch-dependency-install/node_modules/@scope/dep/rescript.json" +printf 'let dependency = 1\n' \ + >"$work/watch-dependency-install/node_modules/@scope/dep/src/Dep.res" +wait_for_file "$work/watch-dependency-install/src/A.js" +terminate_and_wait "$dependency_install_pid" "dependency-install watcher" +checked=$((checked + 1)) + +directory_link "$work/watch-dependency-fallback/packages/dep" \ + "$work/node_modules/dep" +"$ocaml" watch "$work/watch-dependency-fallback" \ + >"$work/watch-dependency-fallback.out" \ + 2>"$work/watch-dependency-fallback.err" & +dependency_fallback_pid=$! +background_pids="$background_pids $dependency_fallback_pid" +wait_for_text "$work/watch-dependency-fallback.err" \ + "no rescript.json or bsconfig.json" +mv "$work/watch-dependency-fallback/node_modules/dep" \ + "$work/watch-dependency-fallback/node_modules/dep-disabled" +wait_for_file "$work/watch-dependency-fallback/src/A.js" +terminate_and_wait "$dependency_fallback_pid" "dependency-fallback watcher" +checked=$((checked + 1)) + +if $file_symlinks_supported; then + symlink_target_marker="$work/watch-symlink-target/after-build.log" + REWATCH_WATCH_FILTER_MARKER="$symlink_target_marker" \ + "$ocaml" watch --after-build "node $command_work/watch-filter-marker.js" \ + "$work/watch-symlink-target" \ + >"$work/watch-symlink-target.out" 2>"$work/watch-symlink-target.err" & + symlink_target_pid=$! + background_pids="$background_pids $symlink_target_pid" + wait_for_file "$work/watch-symlink-target/src/Linked.js" + wait_for_line_count "$symlink_target_marker" 1 + printf 'let linked = 9876\n' \ + >"$work/watch-symlink-external/sub/Linked.res.next" + mv "$work/watch-symlink-external/sub/Linked.res.next" \ + "$work/watch-symlink-external/sub/Linked.res" + wait_for_text "$work/watch-symlink-target/src/Linked.js" "9876" + wait_for_line_count "$symlink_target_marker" 2 + mv "$work/watch-symlink-external/sub/Linked.res" \ + "$work/watch-symlink-external/sub/Linked.res.removed" + wait_for_line_count "$symlink_target_marker" 3 + if [ -e "$work/watch-symlink-target/src/Linked.js" ]; then + echo "OCaml watcher retained output for a dangling source symlink" >&2 + exit 1 + fi + mv "$work/watch-symlink-external/sub/Linked.res.removed" \ + "$work/watch-symlink-external/sub/Linked.res" + wait_for_file "$work/watch-symlink-target/src/Linked.js" + wait_for_line_count "$symlink_target_marker" 4 + mv "$work/watch-symlink-external/sub" \ + "$work/watch-symlink-external/sub.removed" + wait_for_line_count "$symlink_target_marker" 5 + if [ -e "$work/watch-symlink-target/src/Linked.js" ]; then + echo "OCaml watcher retained output after a symlink target parent moved" >&2 + exit 1 + fi + mv "$work/watch-symlink-external/sub.removed" \ + "$work/watch-symlink-external/sub" + wait_for_file "$work/watch-symlink-target/src/Linked.js" + wait_for_line_count "$symlink_target_marker" 6 + terminate_and_wait "$symlink_target_pid" "symlink-target watcher" + checked=$((checked + 1)) +fi + +feature_scope_marker="$work/watch-feature-scope/after-build.log" +scope_block_request="$work/watch-feature-scope/block-request" +scope_block_started="$work/watch-feature-scope/block-started" +scope_block_release="$work/watch-feature-scope/block-release" +REWATCH_SCOPE_REAL_BSC="$RESCRIPT_BSC_EXE" \ +REWATCH_REAL_BSC="$RESCRIPT_BSC_EXE" \ +REWATCH_BSC_PROXY_MODE=scope-block \ +REWATCH_SCOPE_BLOCK_REQUEST="$(command_path "$scope_block_request")" \ +REWATCH_SCOPE_BLOCK_STARTED="$(command_path "$scope_block_started")" \ +REWATCH_SCOPE_BLOCK_RELEASE="$(command_path "$scope_block_release")" \ +RESCRIPT_BSC_EXE="$watch_scope_bsc" \ +REWATCH_WATCH_FILTER_MARKER="$feature_scope_marker" \ + "$ocaml" watch --features other \ + --after-build "node $command_work/watch-filter-marker.js" \ + "$work/watch-feature-scope" \ + >"$work/watch-feature-scope.out" 2>"$work/watch-feature-scope.err" & +feature_scope_pid=$! +background_pids="$background_pids $feature_scope_pid" +if ! wait_for_line_count "$feature_scope_marker" 1; then + echo "OCaml feature-scope watcher did not finish its initial build" >&2 + cat "$work/watch-feature-scope.out" "$work/watch-feature-scope.err" >&2 + exit 1 +fi +: >"$scope_block_request" +printf '{"name":"watch-feature-scope","sources":["src",{"dir":"inactive","feature":"inactive"}],"features":{"other":["inactive"]}}\n' \ + >"$work/watch-feature-scope/rescript.json" +wait_for_file "$scope_block_started" +printf 'let createdDuringBuild = 2\n' \ + >"$work/watch-feature-scope/inactive/CreatedDuringBuild.res" +: >"$scope_block_release" +wait_for_file "$work/watch-feature-scope/inactive/Inactive.js" +wait_for_file "$work/watch-feature-scope/inactive/CreatedDuringBuild.js" +wait_for_line_count "$feature_scope_marker" 3 +if ! line_count_stays "$feature_scope_marker" 3; then + echo "OCaml watcher lost or duplicated an edit during a source-scope transition" >&2 + cat "$work/watch-feature-scope.out" "$work/watch-feature-scope.err" >&2 + exit 1 +fi +terminate_and_wait "$feature_scope_pid" "feature-scope watcher" +checked=$((checked + 1)) + +rust_filter_marker="$work/watch-filter-rust/after-build.log" +REWATCH_WATCH_FILTER_MARKER="$rust_filter_marker" \ + "$rust" watch --features other --filter 'Include\.res$' \ + --after-build "node $command_work/watch-filter-marker.js" \ + "$work/watch-filter-rust" \ + >"$work/watch-filter-rust.out" 2>"$work/watch-filter-rust.err" & +rust_filter_pid=$! +background_pids="$background_pids $rust_filter_pid" +wait_for_file "$work/watch-filter-rust/src/Include.js" +wait_for_line_count "$rust_filter_marker" 1 +if [ -e "$work/watch-filter-rust/src/Exclude.js" ]; then + echo "Rust filter unexpectedly compiled the excluded source initially" >&2 + exit 1 +fi +cp "$work/watch-filter-rust/src/Include.js" "$work/watch-filter-rust-initial.js" +printf 'let value = 2\n' >"$work/watch-filter-rust/src/Include.res" +printf 'let value = 11\n' >"$work/watch-filter-rust/src/Exclude.res" +wait_for_line_count "$rust_filter_marker" 2 +if ! cmp -s "$work/watch-filter-rust-initial.js" \ + "$work/watch-filter-rust/src/Include.js"; then + echo "Rust no longer reproduces the inverted watch-filter event behavior" >&2 + cat "$work/watch-filter-rust.out" "$work/watch-filter-rust.err" >&2 + exit 1 +fi +terminate_and_wait "$rust_filter_pid" "Rust filter watcher" + +ocaml_filter_marker="$work/watch-filter-ocaml/after-build.log" +REWATCH_WATCH_FILTER_MARKER="$ocaml_filter_marker" \ + "$ocaml" watch --features other --filter 'Include\.res$' \ + --after-build "node $command_work/watch-filter-marker.js" \ + "$work/watch-filter-ocaml" \ + >"$work/watch-filter-ocaml.out" 2>"$work/watch-filter-ocaml.err" & +ocaml_filter_pid=$! +background_pids="$background_pids $ocaml_filter_pid" +wait_for_file "$work/watch-filter-ocaml/src/Include.js" +wait_for_line_count "$ocaml_filter_marker" 1 +if [ -e "$work/watch-filter-ocaml/src/Exclude.js" ]; then + echo "OCaml filter unexpectedly compiled the excluded source initially" >&2 + exit 1 +fi +cp "$work/watch-filter-ocaml/src/Include.js" "$work/watch-filter-ocaml-initial.js" +printf 'let value = 2\n' >"$work/watch-filter-ocaml/src/Include.res" +wait_for_line_count "$ocaml_filter_marker" 2 +if cmp -s "$work/watch-filter-ocaml-initial.js" \ + "$work/watch-filter-ocaml/src/Include.js" || \ + ! grep -F '2' "$work/watch-filter-ocaml/src/Include.js" >/dev/null; then + echo "OCaml watch filter did not rebuild its included source" >&2 + cat "$work/watch-filter-ocaml.out" "$work/watch-filter-ocaml.err" >&2 + exit 1 +fi +printf 'let value = 11\n' >"$work/watch-filter-ocaml/src/Exclude.res" +if ! line_count_stays "$ocaml_filter_marker" 2; then + echo "OCaml watch filter rebuilt for an excluded-only edit" >&2 + cat "$work/watch-filter-ocaml.out" "$work/watch-filter-ocaml.err" >&2 + exit 1 +fi +printf 'let value = 21\n' \ + >"$work/watch-filter-ocaml/inactive/Inactive.res" +if ! line_count_stays "$ocaml_filter_marker" 2; then + echo "OCaml watcher rebuilt for a feature-disabled source edit" >&2 + cat "$work/watch-filter-ocaml.out" "$work/watch-filter-ocaml.err" >&2 + exit 1 +fi +terminate_and_wait "$ocaml_filter_pid" "OCaml filter watcher" +checked=$((checked + 1)) + +for implementation in rust ocaml; do + if [ "$implementation" = rust ]; then + executable=$rust + else + executable=$ocaml + fi + quiet_watch_project="$work/quiet-watch-$implementation" + quiet_watch_marker="$quiet_watch_project/after-build.log" + REWATCH_WATCH_FILTER_MARKER="$quiet_watch_marker" \ + "$executable" -q watch \ + --after-build "node $command_work/watch-filter-marker.js" \ + "$quiet_watch_project" \ + >"$work/quiet-watch-$implementation.out" \ + 2>"$work/quiet-watch-$implementation.err" & + quiet_watch_pid=$! + background_pids="$background_pids $quiet_watch_pid" + wait_for_line_count "$quiet_watch_marker" 1 + printf 'let value = 2\n' >"$quiet_watch_project/src/A.res" + wait_for_line_count "$quiet_watch_marker" 2 + printf 'let value =\n' >"$quiet_watch_project/src/A.res" + wait_for_text "$work/quiet-watch-$implementation.err" \ + "This let-binding misses an expression" + if ! kill -0 "$quiet_watch_pid" 2>/dev/null; then + echo "quiet-watch-$implementation: watcher exited after a parse error" >&2 + exit 1 + fi + printf 'let value = 3\n' >"$quiet_watch_project/src/A.res" + wait_for_line_count "$quiet_watch_marker" 3 + rm "$quiet_watch_project/lib/watch.lock" + wait_for_exit "$quiet_watch_pid" + wait "$quiet_watch_pid" + if [ -s "$work/quiet-watch-$implementation.out" ] || \ + grep -F "Incremental build failed" \ + "$work/quiet-watch-$implementation.err" >/dev/null; then + echo "quiet-watch-$implementation: quiet watch emitted progress or a duplicate failure summary" >&2 + cat "$work/quiet-watch-$implementation.out" \ + "$work/quiet-watch-$implementation.err" >&2 + exit 1 + fi + normalize_project_path "$work/quiet-watch-$implementation.err" \ + "$work/quiet-watch-$implementation.err.norm" "$quiet_watch_project" +done +if ! cmp -s "$work/quiet-watch-rust.err.norm" \ + "$work/quiet-watch-ocaml.err.norm"; then + echo "Quiet watch failure diagnostics differ" >&2 + printf '%s\n' '--- Rust stderr ---' >&2 + cat "$work/quiet-watch-rust.err.norm" >&2 + printf '%s\n' '--- OCaml stderr ---' >&2 + cat "$work/quiet-watch-ocaml.err.norm" >&2 + exit 1 +fi +checked=$((checked + 1)) + +run_case clean-missing-dependency exit2 exit2 clean "$work/missing-dependency" +run_case clean-configless-dependency exit2 exit2 clean "$work/configless-dependency" +run_case clean-malformed-dependency exit2 exit2 clean "$work/malformed-dependency" +set +e +"$rust" clean "$work/clean-duplicate-rust" \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +"$ocaml" clean "$work/clean-duplicate-ocaml" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$(classify "$rust_status")" != reject ] || [ "$ocaml_status" -ne 0 ] || \ + [ ! -e "$work/clean-duplicate-rust/src/one/A.js" ] || \ + [ ! -e "$work/clean-duplicate-rust/src/two/A.js" ] || \ + [ -e "$work/clean-duplicate-rust/lib/bs/marker" ] || \ + [ -e "$work/clean-duplicate-ocaml/src/one/A.js" ] || \ + [ -e "$work/clean-duplicate-ocaml/src/two/A.js" ] || \ + [ -e "$work/clean-duplicate-ocaml/lib/bs/marker" ]; then + echo "clean-duplicate-modules: cleanup behavior differs unexpectedly" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +checked=$((checked + 1)) +set +e +"$rust" watch "$work/missing-dependency" >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +set -e +if [ "$rust_status" -ne 2 ]; then + echo "watch-missing-dependency: expected Rust exit 2, got $rust_status" >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + exit 1 +fi +"$ocaml" watch "$work/missing-dependency" \ + >"$work/ocaml.out" 2>"$work/ocaml.err" & +missing_dependency_watch_pid=$! +background_pids="$background_pids $missing_dependency_watch_pid" +wait_for_text "$work/ocaml.err" "Could not resolve dependency absent" +if ! kill -0 "$missing_dependency_watch_pid" 2>/dev/null; then + echo "OCaml watcher exited after its initial dependency error" >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 +fi +printf '{"name":"missing-dependency","sources":["src"]}\n' \ + >"$work/missing-dependency/rescript.json" +wait_for_file "$work/missing-dependency/src/A.js" +terminate_and_wait "$missing_dependency_watch_pid" \ + "missing-dependency watcher" +if $windows_posix_shell; then + # MSYS cannot deliver the graceful console event that lets a native watcher + # remove its lock. Its external termination intentionally leaves a stale + # watch lock, so remove that harness artifact before checking failure paths. + rm -f "$work/missing-dependency/lib/watch.lock" +fi +printf '{"name":"missing-dependency","sources":["src"],"dependencies":["absent"]}\n' \ + >"$work/missing-dependency/rescript.json" +checked=$((checked + 1)) +run_cwd_case format-missing-dependency exit2 exit2 \ + "$work/missing-dependency" format +run_cwd_case format-configless-dependency exit2 exit2 \ + "$work/configless-dependency" format +run_cwd_case format-malformed-dependency exit2 exit2 \ + "$work/malformed-dependency" format +if [ -e "$work/missing-dependency/lib/build.lock" ] || \ + [ -e "$work/missing-dependency/lib/watch.lock" ]; then + echo "OCaml dependency failures left a build or watch lock behind" >&2 + exit 1 +fi + +set +e +(cd "$project/src" && "$rust" format --check) \ + >"$work/rust.out" 2>"$work/rust.err" +rust_status=$? +(cd "$project/src" && "$ocaml" format --check) \ + >"$work/ocaml.out" 2>"$work/ocaml.err" +ocaml_status=$? +set -e +if [ "$(classify "$rust_status")" != reject ] || \ + [ "$(classify "$ocaml_status")" != reject ]; then + printf 'format-nested: expected both implementations to reject, got Rust=%s/OCaml=%s\n' \ + "$rust_status" "$ocaml_status" >&2 + exit 1 +fi +checked=$((checked + 1)) + +printf 'Command validation cases: %d; expected outcomes matched\n' "$checked" diff --git a/rewatch-ocaml/tests/check_config_acceptance.sh b/rewatch-ocaml/tests/check_config_acceptance.sh new file mode 100755 index 00000000000..c414fcb1083 --- /dev/null +++ b/rewatch-ocaml/tests/check_config_acceptance.sh @@ -0,0 +1,112 @@ +#!/bin/bash +set -eu + +root=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +rust=${1:-$root/rewatch/target/debug/rescript} +ocaml=${2:-$root/_build/default/rewatch-ocaml/rescript_ocaml.exe} +cases=$root/rewatch-ocaml/tests/config_acceptance_cases.tsv + +rust=$(realpath "$rust") +ocaml=$(realpath "$ocaml") +mkdir -p "$root/tmp" +work=$(mktemp -d "$root/tmp/rewatch-config-acceptance-XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/src" "$work/node_modules/dep/lib/ocaml" "$work/node_modules/ppx" +printf 'let value = 1\n' >"$work/src/A.res" + +default_bsc=$root/_build/default/compiler/bsc/rescript_compiler_main.exe +default_runtime=$root/packages/@rescript/runtime +case $(uname -s) in + MINGW*|MSYS*) + default_bsc=$(cygpath -w "$default_bsc") + default_runtime=$(cygpath -w "$default_runtime") + ;; +esac +export RESCRIPT_BSC_EXE=${RESCRIPT_BSC_EXE:-$default_bsc} +export RESCRIPT_RUNTIME=${RESCRIPT_RUNTIME:-$default_runtime} + +checked=0 +divergences=0 +diagnostic_checks=0 +while IFS=$'\t' read -r area name expected json; do + if [[ -z "$area" || "$area" == \#* ]]; then + continue + fi + printf '%s\n' "$json" >"$work/rescript.json" + set +e + "$rust" compiler-args "$work/src/A.res" >"$work/rust.out" 2>"$work/rust.err" + rust_status=$? + "$ocaml" compiler-args "$work/src/A.res" >"$work/ocaml.out" 2>"$work/ocaml.err" + ocaml_status=$? + set -e + + if [[ "$rust_status" -eq 0 ]]; then + rust_actual=accept + elif [[ "$rust_status" -eq 101 ]]; then + rust_actual=panic + else + rust_actual=reject + fi + if [[ "$ocaml_status" -eq 0 ]]; then + ocaml_actual=accept + else + ocaml_actual=reject + fi + rust_expected=${expected%%/*} + if [[ "$expected" == */* ]]; then + ocaml_expected=${expected#*/} + compare_arguments=false + divergences=$((divergences + 1)) + else + ocaml_expected=$expected + compare_arguments=true + fi + if [[ "$rust_actual" != "$rust_expected" || "$ocaml_actual" != "$ocaml_expected" ]]; then + printf 'Config case %s/%s: expected Rust=%s/OCaml=%s, got Rust=%s/OCaml=%s\n' \ + "$area" "$name" "$rust_expected" "$ocaml_expected" \ + "$rust_status" "$ocaml_status" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" >&2 + cat "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" >&2 + cat "$work/ocaml.err" >&2 + exit 1 + fi + diagnostic_fragment= + case "$area" in + jsx) diagnostic_fragment=jsx ;; + source-map) diagnostic_fragment=sourceMap ;; + esac + if [[ -n "$diagnostic_fragment" && "$rust_expected" != accept ]]; then + if ! grep -Fi "$diagnostic_fragment" "$work/rust.err" >/dev/null || + ! grep -Fi "$diagnostic_fragment" "$work/ocaml.err" >/dev/null; then + printf 'Config case %s/%s lost the %s diagnostic context\n' \ + "$area" "$name" "$diagnostic_fragment" >&2 + printf '%s\n' '--- Rust output ---' >&2 + cat "$work/rust.out" "$work/rust.err" >&2 + printf '%s\n' '--- OCaml output ---' >&2 + cat "$work/ocaml.out" "$work/ocaml.err" >&2 + exit 1 + fi + diagnostic_checks=$((diagnostic_checks + 1)) + fi + if [[ "$compare_arguments" == true && "$rust_expected" == accept ]] && + ! node -e ' + const fs = require("fs"); + const assert = require("assert"); + assert.deepStrictEqual( + JSON.parse(fs.readFileSync(process.argv[1], "utf8")), + JSON.parse(fs.readFileSync(process.argv[2], "utf8")), + ); + ' "$work/rust.out" "$work/ocaml.out"; then + printf 'Config case %s/%s produced different compiler arguments\n' \ + "$area" "$name" >&2 + diff -u "$work/rust.out" "$work/ocaml.out" >&2 || true + exit 1 + fi + checked=$((checked + 1)) +done <"$cases" + +printf 'Configuration cases: %d (%d documented divergences, %d JSX/source-map diagnostic checks); Rust/OCaml expectations and parity arguments matched\n' \ + "$checked" "$divergences" "$diagnostic_checks" diff --git a/rewatch-ocaml/tests/check_interactive_output.sh b/rewatch-ocaml/tests/check_interactive_output.sh new file mode 100755 index 00000000000..1f599c3e87b --- /dev/null +++ b/rewatch-ocaml/tests/check_interactive_output.sh @@ -0,0 +1,790 @@ +#!/bin/bash +set -eu + +root=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +rust=${1:-$root/rewatch/target/debug/rescript} +ocaml=${2:-$root/_build/default/rewatch-ocaml/rescript_ocaml.exe} +rust=$(realpath "$rust") +ocaml=$(realpath "$ocaml") +work=$(mktemp -d "${TMPDIR:-/tmp}/rewatch-interactive-output-XXXXXX") +windows_posix_shell=false +compare_rust_watch_output=true +case $(uname -s) in + CYGWIN*|MINGW*|MSYS*) windows_posix_shell=true ;; + # Rust's macOS watcher can classify an ordinary source write as one or more + # structural events and legitimately perform full rebuilds. Linux provides + # the stable event classification needed for exact incremental comparison; + # macOS still exercises the OCaml watch presentation and recovery paths. + Darwin) compare_rust_watch_output=false ;; +esac + +if $windows_posix_shell; then + echo "Skipping interactive output gate: a POSIX pseudo-terminal does not exercise the native Windows console" + exit 0 +fi + +command_path() { + if $windows_posix_shell; then + cygpath -am "$1" + else + printf '%s\n' "$1" + fi +} +active_script_pid="" +active_watch_project="" +active_watch_transcript="" +wait_for_pid_gone() { + pid=$1 + attempts=${2:-200} + while [ "$attempts" -gt 0 ]; do + if ! kill -0 "$pid" 2>/dev/null; then return 0; fi + attempts=$((attempts - 1)) + sleep 0.1 + done + return 1 +} +cleanup() { + if [ -n "$active_script_pid" ]; then + if [ -n "$active_watch_project" ]; then + rm -f "$active_watch_project/lib/watch.lock" + fi + if ! wait_for_pid_gone "$active_script_pid" 50; then + kill -TERM "$active_script_pid" 2>/dev/null || true + fi + if ! wait_for_pid_gone "$active_script_pid" 50; then + kill -KILL "$active_script_pid" 2>/dev/null || true + fi + wait "$active_script_pid" 2>/dev/null || true + fi + rm -rf "$work" +} +trap cleanup EXIT + +if ! command -v script >/dev/null 2>&1; then + echo "Interactive output gate requires the util-linux script command" >&2 + exit 1 +fi + +# macOS buffers typescript files for up to 30 seconds unless -F is used. The +# watcher checks below must observe each message as soon as it is written. + +normalize_output() { + sed -E $'s/\033\\[[0-9;]*[[:alpha:]]//g' \ + | sed -e 's/\[clean\]/🧹/g' -e 's/\[parse\]/🧱/g' \ + -e 's/\[build\]/🤺/g' -e 's/\[ok\]/✅/g' \ + -e 's/\[warn\]/⚠️/g' -e 's/\[error\]/❌/g' +} + +for implementation in rust ocaml; do + mkdir -p "$work/$implementation/src" + printf '{"name":"interactive-output","sources":["src"],"namespace":"Interactive"}\n' \ + >"$work/$implementation/rescript.json" + printf 'let value = 1\n' >"$work/$implementation/src/A.res" + printf 'let value: int\n' >"$work/$implementation/src/A.resi" + cp -R "$work/$implementation" "$work/$implementation-after-build" + cp -R "$work/$implementation" "$work/$implementation-parse-warning" + cp -R "$work/$implementation" "$work/$implementation-watch" + cp -R "$work/$implementation" "$work/$implementation-initial-failure-watch" + printf 'let value = (\n' \ + >"$work/$implementation-initial-failure-watch/src/A.res" + cp -R "$root/rewatch-ocaml/tests/basic" \ + "$work/$implementation-partial-initial-failure-watch" + printf 'let answer: int = "not an int"\n' \ + >"$work/$implementation-partial-initial-failure-watch/src/B.res" + cp -R "$work/$implementation" "$work/$implementation-warning-watch" + printf '%s\n' \ + '{"name":"interactive-output","sources":["src"],"namespace":"Interactive","package-specs":{"module":"es6","in-source":true}}' \ + >"$work/$implementation-warning-watch/rescript.json" +done + +cat >"$work/after-build-marker.sh" <<'EOF' +#!/bin/sh +printf '%s\n' AFTER_BUILD_MARKER +EOF +chmod +x "$work/after-build-marker.sh" +printf 'console.log("AFTER_BUILD_MARKER")\n' >"$work/after-build-marker.js" +cat >"$work/after-build-input.js" <<'EOF' +process.stdin.setEncoding("utf8"); +process.stdin.once("data", input => { + console.log(`AFTER_BUILD_INPUT:${input.trim()}`); +}); +EOF + +export RESCRIPT_BSC_EXE=${RESCRIPT_BSC_EXE:-$root/_build/default/compiler/bsc/rescript_compiler_main.exe} +export RESCRIPT_RUNTIME=${RESCRIPT_RUNTIME:-$root/packages/@rescript/runtime} +parse_warning_bsc="$root/_build/default/tests/rewatch_ounit_tests/rewatch_bsc_test_proxy.exe" +after_build_command="$work/after-build-marker.sh" +if $windows_posix_shell; then + RESCRIPT_BSC_EXE=$(command_path "$RESCRIPT_BSC_EXE") + RESCRIPT_RUNTIME=$(command_path "$RESCRIPT_RUNTIME") + parse_warning_bsc=$(command_path "$parse_warning_bsc") + after_build_command="node $(command_path "$work/after-build-marker.js")" +fi + +capture() { + implementation=$1 + executable=$2 + transcript="$work/$implementation.tty" + command_executable=$(command_path "$executable") + command_project=$(command_path "$work/$implementation") + if [ "$(uname -s)" = Darwin ]; then + script -qF "$transcript" env -u NO_COLOR \ + "TERM=xterm" \ + "CLICOLOR=1" \ + "CLICOLOR_FORCE=0" \ + "RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE" \ + "RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME" \ + "$executable" build "$work/$implementation" --no-timing >/dev/null + else + script -qefc \ + "env -u NO_COLOR TERM=xterm CLICOLOR=1 CLICOLOR_FORCE=0 RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME $command_executable build $command_project --no-timing" \ + "$transcript" >/dev/null + fi + tr '\r' '\n' <"$transcript" \ + | normalize_output \ + | grep -E '^\[[123]/3\] .* (Cleaned|Parsed|Compiled) |^✅ Finished compilation in ' \ + >"$work/$implementation.phases" +} + +echo "Checking interactive build output..." +capture rust "$rust" +capture ocaml "$ocaml" + +require_spinner_frames() { + implementation=$1 + if ! grep -F $'\033[1m\033[2m[2/3]\033[0m' \ + "$work/$implementation.tty" >/dev/null; then + echo "$implementation did not render the interactive step style" >&2 + cat "$work/$implementation.tty" >&2 + exit 1 + fi + tr '\r' '\n' <"$work/$implementation.tty" \ + | normalize_output \ + >"$work/$implementation.frames" + if ! grep -E '^\[2/3\] 🧱 Parsing\.\.\. .+ [0-9]+/1' \ + "$work/$implementation.frames" >/dev/null || \ + ! grep -E '^\[3/3\] 🤺 Compiling\.\.\. .+ [0-9]+/2' \ + "$work/$implementation.frames" >/dev/null; then + echo "$implementation did not render both live spinner phases" >&2 + cat "$work/$implementation.frames" >&2 + exit 1 + fi +} + +require_spinner_frames rust +require_spinner_frames ocaml + +if ! cmp -s "$work/rust.phases" "$work/ocaml.phases"; then + echo "Interactive phase output differs" >&2 + printf '%s\n' '--- Rust phases ---' >&2 + cat "$work/rust.phases" >&2 + printf '%s\n' '--- OCaml phases ---' >&2 + cat "$work/ocaml.phases" >&2 + exit 1 +fi + +cat >"$work/expected" <<'EOF' +[1/3] 🧹 Cleaned 0/0 in 0.00s +[2/3] 🧱 Parsed 1 source files in 0.00s +[3/3] 🤺 Compiled 1 modules in 0.00s +✅ Finished compilation in 0.00s +EOF + +if ! cmp -s "$work/expected" "$work/ocaml.phases"; then + echo "Interactive phase output no longer has the expected stable shape" >&2 + cat "$work/ocaml.phases" >&2 + exit 1 +fi + +capture_parse_warning_order() { + implementation=$1 + executable=$2 + transcript="$work/$implementation-parse-warning.tty" + project="$work/$implementation-parse-warning" + command_executable=$(command_path "$executable") + command_project=$(command_path "$project") + if [ "$(uname -s)" = Darwin ]; then + script -qF "$transcript" env -u NO_COLOR \ + "TERM=xterm" \ + "CLICOLOR=1" \ + "CLICOLOR_FORCE=0" \ + "REWATCH_REAL_BSC=$RESCRIPT_BSC_EXE" \ + "REWATCH_BSC_PROXY_MODE=parse-warning" \ + "RESCRIPT_BSC_EXE=$parse_warning_bsc" \ + "RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME" \ + "$executable" build "$project" --no-timing >/dev/null + else + script -qefc \ + "env -u NO_COLOR TERM=xterm CLICOLOR=1 CLICOLOR_FORCE=0 REAL_BSC_EXE=$RESCRIPT_BSC_EXE REWATCH_REAL_BSC=$RESCRIPT_BSC_EXE REWATCH_BSC_PROXY_MODE=parse-warning RESCRIPT_BSC_EXE=$parse_warning_bsc RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME $command_executable build $command_project --no-timing" \ + "$transcript" >/dev/null + fi + tr '\r' '\n' <"$transcript" \ + | normalize_output \ + | grep -E '(^\[[123]/3\] .* Parsed |PARSE_WARNING_MARKER)' \ + >"$work/$implementation-parse-warning.order" +} + +echo "Checking interactive parser warnings..." +capture_parse_warning_order rust "$rust" +capture_parse_warning_order ocaml "$ocaml" + +if ! cmp -s "$work/rust-parse-warning.order" \ + "$work/ocaml-parse-warning.order"; then + echo "Parser warning phase order differs" >&2 + printf '%s\n' '--- Rust order ---' >&2 + cat "$work/rust-parse-warning.order" >&2 + printf '%s\n' '--- OCaml order ---' >&2 + cat "$work/ocaml-parse-warning.order" >&2 + exit 1 +fi + +if ! sed -n '1p' "$work/ocaml-parse-warning.order" \ + | grep -E '^\[2/3\] .* Parsed 1 source files in 0.00s$' >/dev/null; then + echo "Parser warnings were emitted before the completed parse phase" >&2 + cat "$work/ocaml-parse-warning.order" >&2 + exit 1 +fi + +capture_after_build_order() { + implementation=$1 + executable=$2 + transcript="$work/$implementation-after-build.tty" + project="$work/$implementation-after-build" + command_executable=$(command_path "$executable") + command_project=$(command_path "$project") + if [ "$(uname -s)" = Darwin ]; then + script -qF "$transcript" env -u NO_COLOR \ + "TERM=xterm" \ + "CLICOLOR=1" \ + "CLICOLOR_FORCE=0" \ + "RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE" \ + "RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME" \ + "$executable" build --after-build "$after_build_command" \ + "$project" --no-timing >/dev/null + else + script -qefc \ + "env -u NO_COLOR TERM=xterm CLICOLOR=1 CLICOLOR_FORCE=0 RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME $command_executable build --after-build '$after_build_command' $command_project --no-timing" \ + "$transcript" >/dev/null + fi + tr '\r' '\n' <"$transcript" \ + | normalize_output \ + | grep -E '^(✅ Finished compilation in |AFTER_BUILD_MARKER$)' \ + | sed -E 's/in [0-9]+\.[0-9]+s$/in 0.00s/' \ + >"$work/$implementation-after-build.order" +} + +echo "Checking interactive after-build output..." +capture_after_build_order rust "$rust" +capture_after_build_order ocaml "$ocaml" + +cat >"$work/expected-after-build-order" <<'EOF' +✅ Finished compilation in 0.00s +AFTER_BUILD_MARKER +EOF + +for implementation in rust ocaml; do + if ! cmp -s "$work/expected-after-build-order" \ + "$work/$implementation-after-build.order"; then + echo "$implementation ran one-shot --after-build in the wrong phase" >&2 + cat "$work/$implementation-after-build.order" >&2 + exit 1 + fi +done + +if $windows_posix_shell; then + for implementation in rust ocaml; do + if [ "$implementation" = rust ]; then executable=$rust; else executable=$ocaml; fi + command_executable=$(command_path "$executable") + command_project=$(command_path "$work/$implementation-after-build") + input_script=$(command_path "$work/after-build-input.js") + transcript="$work/$implementation-after-build-input.tty" + printf 'from-terminal\n' | script -qefc \ + "env -u NO_COLOR TERM=xterm CLICOLOR=1 CLICOLOR_FORCE=0 RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME $command_executable build --after-build 'node $input_script' $command_project --no-timing" \ + "$transcript" >/dev/null + if ! tr '\r' '\n' <"$transcript" \ + | grep -F 'AFTER_BUILD_INPUT:from-terminal' >/dev/null; then + echo "$implementation did not pass terminal input to --after-build" >&2 + cat "$transcript" >&2 + exit 1 + fi + done +fi + +capture_quiet_build() { + implementation=$1 + executable=$2 + transcript="$work/$implementation-quiet.tty" + command_executable=$(command_path "$executable") + command_project=$(command_path "$work/$implementation") + if [ "$(uname -s)" = Darwin ]; then + script -qF "$transcript" env -u NO_COLOR \ + "TERM=xterm" \ + "CLICOLOR=1" \ + "CLICOLOR_FORCE=0" \ + "RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE" \ + "RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME" \ + "$executable" -q build "$work/$implementation" >/dev/null + else + script -qefc \ + "env -u NO_COLOR TERM=xterm CLICOLOR=1 CLICOLOR_FORCE=0 RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME $command_executable -q build $command_project" \ + "$transcript" >/dev/null + fi + if tr '\r' '\n' <"$transcript" \ + | grep -E '(Cleaned|Parsed|Parsing\.\.\.|Compiled|Compiling\.\.\.|Finished .*compilation)' >/dev/null; then + echo "$implementation quiet interactive build emitted progress" >&2 + cat "$transcript" >&2 + exit 1 + fi +} + +echo "Checking quiet interactive builds..." +capture_quiet_build rust "$rust" +capture_quiet_build ocaml "$ocaml" + +capture_clean() { + implementation=$1 + executable=$2 + transcript="$work/$implementation-clean.tty" + command_executable=$(command_path "$executable") + command_project=$(command_path "$work/$implementation") + if [ "$(uname -s)" = Darwin ]; then + script -qF "$transcript" env -u NO_COLOR \ + "TERM=xterm" \ + "CLICOLOR=1" \ + "CLICOLOR_FORCE=0" \ + "$executable" clean "$work/$implementation" >/dev/null + else + script -qefc \ + "env -u NO_COLOR TERM=xterm CLICOLOR=1 CLICOLOR_FORCE=0 $command_executable clean $command_project" \ + "$transcript" >/dev/null + fi + # The initial generic label is overwritten by the first package label on the + # same terminal line, so it is not part of the visible phase sequence. + tr '\r' '\n' <"$transcript" \ + | normalize_output \ + | grep -E '^\[[12]/2\] 🧹 (Cleaning|Cleaned)' \ + | awk '$0 != "[1/2] 🧹 Cleaning compiler assets..."' \ + | sed -E 's/in [0-9]+\.[0-9]+s$/in 0.00s/' \ + >"$work/$implementation-clean.phases" +} + +echo "Checking interactive clean output..." +capture_clean rust "$rust" +capture_clean ocaml "$ocaml" + +if ! cmp -s "$work/rust-clean.phases" "$work/ocaml-clean.phases"; then + echo "Interactive clean phase output differs" >&2 + printf '%s\n' '--- Rust clean phases ---' >&2 + cat "$work/rust-clean.phases" >&2 + printf '%s\n' '--- OCaml clean phases ---' >&2 + cat "$work/ocaml-clean.phases" >&2 + exit 1 +fi + +cat >"$work/expected-clean" <<'EOF' +[1/2] 🧹 Cleaning interactive-output... +[1/2] 🧹 Cleaned compiler assets in 0.00s +[2/2] 🧹 Cleaning .js files... +[2/2] 🧹 Cleaned .js files in 0.00s +EOF + +if ! cmp -s "$work/expected-clean" "$work/ocaml-clean.phases"; then + echo "Interactive clean output no longer has the expected stable shape" >&2 + cat "$work/ocaml-clean.phases" >&2 + exit 1 +fi + +capture_quiet_clean() { + implementation=$1 + executable=$2 + transcript="$work/$implementation-clean-quiet.tty" + command_executable=$(command_path "$executable") + command_project=$(command_path "$work/$implementation") + if [ "$(uname -s)" = Darwin ]; then + script -qF "$transcript" env -u NO_COLOR \ + "TERM=xterm" \ + "CLICOLOR=1" \ + "CLICOLOR_FORCE=0" \ + "$executable" -q clean "$work/$implementation" >/dev/null + else + script -qefc \ + "env -u NO_COLOR TERM=xterm CLICOLOR=1 CLICOLOR_FORCE=0 $command_executable -q clean $command_project" \ + "$transcript" >/dev/null + fi + if tr '\r' '\n' <"$transcript" | grep -E '(Cleaning|Cleaned)' >/dev/null; then + echo "$implementation quiet interactive clean emitted progress" >&2 + cat "$transcript" >&2 + exit 1 + fi +} + +echo "Checking quiet interactive cleans..." +capture_quiet_clean rust "$rust" +capture_quiet_clean ocaml "$ocaml" + +wait_for_text() { + path=$1 + pattern=$2 + count=$3 + attempts=0 + while [ "$attempts" -lt 200 ]; do + actual=$(grep -cF "$pattern" "$path" 2>/dev/null || true) + actual=${actual:-0} + if [ "$actual" -ge "$count" ]; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 0.1 + done + printf 'Timed out waiting for occurrence %s of %s in %s\n' \ + "$count" "$pattern" "$path" >&2 + if [ -f "$path" ]; then cat "$path" >&2; fi + return 1 +} + +wait_for_file() { + path=$1 + attempts=0 + while [ "$attempts" -lt 200 ]; do + if [ -f "$path" ]; then return 0; fi + attempts=$((attempts + 1)) + sleep 0.1 + done + printf 'Timed out waiting for %s\n' "$path" >&2 + return 1 +} + +# Opening an existing source with shell redirection truncates it first. Some +# native watcher backends report that as a structural change, which correctly +# requests a full rebuild. Equal-length writes without truncation make these +# fixtures exercise the content-change/incremental path they are testing. +overwrite_line_in_place() { + path=$1 + line=$2 + current_size=$(wc -c <"$path" | tr -d ' ') + replacement_size=$(printf '%s\n' "$line" | wc -c | tr -d ' ') + if [ "$current_size" -ne "$replacement_size" ]; then + echo "In-place test update changed size for $path" >&2 + return 1 + fi + printf '%s\n' "$line" | dd of="$path" conv=notrunc 2>/dev/null +} + +stop_active_watch() { + rm -f "$active_watch_project/lib/watch.lock" + if ! wait_for_pid_gone "$active_script_pid"; then + echo "Timed out waiting for the interactive watcher to stop" >&2 + if [ -f "$active_watch_transcript" ]; then + cat "$active_watch_transcript" >&2 + fi + return 1 + fi + status=0 + wait "$active_script_pid" || status=$? + active_script_pid="" + active_watch_project="" + active_watch_transcript="" + return "$status" +} + +capture_watch_rebuild() { + local implementation=$1 + local executable=$2 + local project="$work/$implementation-watch" + local transcript="$work/$implementation-watch.tty" + local command_executable=$(command_path "$executable") + local command_project=$(command_path "$project") + if [ "$(uname -s)" = Darwin ]; then + script -qF "$transcript" env -u NO_COLOR \ + "TERM=xterm" \ + "CLICOLOR=1" \ + "CLICOLOR_FORCE=0" \ + "RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE" \ + "RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME" \ + "$executable" watch --clear-screen "$project" >/dev/null & + else + script -qefc \ + "env -u NO_COLOR TERM=xterm CLICOLOR=1 CLICOLOR_FORCE=0 RESCRIPT_BSC_EXE=$RESCRIPT_BSC_EXE RESCRIPT_RUNTIME=$RESCRIPT_RUNTIME $command_executable watch --clear-screen $command_project" \ + "$transcript" >/dev/null & + fi + active_script_pid=$! + active_watch_project=$project + active_watch_transcript=$transcript + if ! wait_for_text "$transcript" "Finished initial compilation" 1; then + return 1 + fi + overwrite_line_in_place "$project/src/A.res" 'let value = 2' + if ! wait_for_text "$transcript" "Finished incremental compilation" 1; then + return 1 + fi + cp "$transcript" "$work/$implementation-watch-phases.tty" + overwrite_line_in_place "$project/src/A.res" 'let value = (' + if ! wait_for_text "$transcript" "Build failed. Watching for changes..." 1; then + return 1 + fi + overwrite_line_in_place "$project/src/A.res" 'let value = 3' + if ! wait_for_text "$transcript" "Finished incremental compilation" 2; then + return 1 + fi + printf '%s\n' \ + '{"name":"interactive-output","sources":["src"],"namespace":"Interactive","package-specs":{"module":"esmodule","in-source":true,"suffix":".mjs"}}' \ + >"$project/rescript.next" + mv "$project/rescript.next" "$project/rescript.json" + if ! wait_for_text "$transcript" "Change detected. Full rebuild..." 1 || \ + ! wait_for_text "$transcript" "Finished compilation" 1; then + return 1 + fi + stop_active_watch + tr '\r' '\n' <"$work/$implementation-watch-phases.tty" \ + | normalize_output \ + | sed -E 's/in [0-9]+\.[0-9]+s/in