This file provides guidance to AI coding assistants when working with code in this repository.
# Build the platform toolchain (default target)
make
# Build the platform toolchain + stdlib
make lib
# Build the platform toolchain + stdlib and run tests
make test
# Format code
make format
# Check formatting
make checkformatThe Makefile’s targets build on each other in this order:
yarn-installruns automatically for targets that need JavaScript tooling (lib, playground, tests, formatting, etc.).build(default target) builds the toolchain binaries (all copied intopackages/@rescript/<platform>/bin):compilerbuilds the dune executables (bsc,rescript-*,ounit_tests, etc.).rewatchbuilds the Rust-based ReScript build system and CLI.
libuses those toolchain outputs to build the runtime sources.- Test targets (
make test,make test-syntax, etc.) reuse everything above.
-
We are NOT bound by OCaml compatibility - The ReScript compiler originated as a fork of the OCaml compiler, but we maintain our own AST and can make breaking changes. Focus on what's best for ReScript's JavaScript compilation target.
-
Never modify
parsetree0.ml- Existing PPX (parser extensions) rely on this frozen v0 version. When changingparsetree.ml, always update the mapping modulesast_mapper_from0.mlandast_mapper_to0.mlto maintain PPX compatibility while allowing the main parsetree to evolve -
Missing test coverage - Always add tests for syntax, lambda, and end-to-end behavior
-
Test early and often - Add tests immediately after modifying each compiler layer to catch problems early, rather than waiting until all changes are complete
-
Use underscore patterns carefully - Don't use
_patterns as lazy placeholders for new language features that then get forgotten. Only use them when you're certain the value should be ignored for that specific case. Ensure all new language features are handled correctly and completely across all compiler layers -
Avoid
let _ = …for side effects - If you need to call a function only for its side effects, useignore expr(or bind the result and thread state explicitly). Do not writelet _ = expr in (), and do not discard stateful results—plumb them through instead. -
Don't use unit
()with mandatory labeled arguments - When a function has a mandatory labeled argument (like~config), don't add a trailing()parameter. The labeled argument already prevents accidental partial application. Only use()when all parameters are optional and you need to force evaluation. Example:let forceDelayedItems ~config = ...notlet forceDelayedItems ~config () = ... -
Be careful with similar constructor names across different IRs - Note that
Lam(Lambda IR) andLambda(typed lambda) have variants with similar constructor names likeLtrywith, but they represent different things in different compilation phases. -
Avoid warning suppressions - Never use
[@@warning "..."]to silence warnings. Instead, fix the underlying issue properly -
Skip trailing
; _in record patterns - The warning it targets is disabled in this codebase, so prefer{field = x}over{field = x; _}. -
Do not introduce new keywords unless absolutely necessary - Try to find ways to implement features without reserving keywords, as seen with the "catch" implementation that avoids making it a keyword.
ReScript Source (.res)
↓ (ReScript Parser - compiler/syntax/)
Surface Syntax Tree
↓ (Frontend transformations - compiler/frontend/)
Surface Syntax Tree
↓ (OCaml Type Checker - compiler/ml/)
Typedtree
↓ (Lambda compilation - compiler/core/lam_*)
Lambda IR
↓ (JS compilation - compiler/core/js_*)
JS IR
↓ (JS output - compiler/core/js_dump*)
JavaScript Code
compiler/
├── syntax/ # ReScript syntax parser (MIT licensed)
├── frontend/ # AST transformations, FFI processing
├── ml/ # OCaml compiler infrastructure
├── core/ # Core compilation (lam_*, js_* files)
├── ext/ # Extended utilities and data structures
└── gentype/ # TypeScript generation
analysis/ # Language server and tooling
packages/@rescript/
├── runtime/ # Runtime and standard library
└── <platform>/ # Platform-specific binaries
tests/
├── syntax_tests/ # Parser/syntax layer tests
├── tests/ # Runtime library tests
├── build_tests/ # Integration tests
└── ounit_tests/ # Compiler unit tests
-
Understand which layer you're working on:
- Syntax layer (
compiler/syntax/): Parsing and surface syntax - ML layer (
compiler/ml/): Type checking and AST transformations - Lambda layer (
compiler/core/lam_*): Intermediate representation and optimizations - JS layer (
compiler/core/js_*): JavaScript generation
- Syntax layer (
-
Always run appropriate tests:
# For compiler or stdlib changes make test # For syntax changes make test-syntax # For specific test types make test-syntax-roundtrip make test-gentype make test-analysis
-
Test your changes thoroughly:
- Syntax tests for new language features
- Integration tests for behavior changes
- Unit tests for utility functions
- Always check JavaScript output quality
# Source code (for debugging preprocessing)
./cli/bsc.js -dsource myfile.res
# Parse tree (surface syntax after parsing)
./cli/bsc.js -dparsetree myfile.res
# Typed tree (after type checking)
./cli/bsc.js -dtypedtree myfile.res
# Raw lambda (unoptimized intermediate representation)
./cli/bsc.js -drawlambda myfile.res
# Use lambda printing for debugging (add in compiler/core/lam_print.ml)- JavaScript formatting issues: Check
compiler/ml/pprintast.ml - Type checking issues: Look in
compiler/ml/type checker modules - Optimization bugs: Check
compiler/core/lam_*.mlanalysis passes - Code generation bugs: Look in
compiler/core/js_*.mlmodules
- Always for new language features
- Always for bug fixes
- When modifying analysis passes
- When changing JavaScript generation
- Syntax tests (
tests/syntax_tests/) - Parser validation - Integration tests (
tests/tests/) - End-to-end behavior - Unit tests (
tests/ounit_tests/) - Compiler functions - Build tests (
tests/build_tests/) - Error cases and edge cases - Type tests (
tests/build_tests/super_errors/) - Type checking behavior
# Build compiler
make
# Build compiler in watch mode
make watch
# Build compiler and standard library
make lib
# Build compiler and standard library and run all tests
make test
# Build artifacts and update artifact list
make artifacts
# Clean build
make clean# Specific test types
make test-syntax # Syntax parser tests
make test-syntax-roundtrip # Roundtrip syntax tests
make test-gentype # GenType tests
make test-analysis # Analysis tests
make test-tools # Tools tests
make test-rewatch # Rewatch tests
# Single file debugging
./cli/bsc.js myfile.res# Format code
make format
# Check formatting
make checkformat
# Lint with Biome
npm run check
npm run check:all
# TypeScript type checking
npm run typecheckThe compiler is designed for fast feedback loops and scales to large codebases:
- Avoid meaningless symbols in generated JavaScript
- Maintain readable JavaScript output
- Consider compilation speed impact of changes
- Use appropriate optimization passes in Lambda and JS IRs
- Profile before and after performance-related changes
- OCaml code: snake_case (e.g.,
to_string) - ReScript code: camelCase (e.g.,
toString)
- Use DCO sign-off:
Signed-Off-By: Your Name <email> - Include appropriate tests with all changes
- Build must pass before committing
- Follow existing patterns in the codebase
- Prefer existing utility functions over reinventing
- Comment complex algorithms and non-obvious logic
- Maintain backward compatibility where possible
- OCaml: 5.3.0+ with opam
- Build System: dune with profiles (dev, release, browser)
- JavaScript: Node.js 20+ for tooling
- Rust: Toolchain needed for rewatch
- Update parser in
compiler/syntax/ - Update AST definitions in
compiler/ml/ - Implement type checking in
compiler/ml/ - Add Lambda IR handling in
compiler/core/lam_* - Implement JS generation in
compiler/core/js_* - Add comprehensive tests
- Identify which compilation phase has the issue
- Use appropriate debugging flags (
-dparsetree,-dtypedtree) - Check intermediate representations
- Add debug output in relevant compiler modules
- Verify with minimal test cases
- Remember Lambda IR is the core optimization layer
- All
lam_*.mlfiles process this representation - Use
lam_print.mlfor debugging lambda expressions - Test both with and without optimization passes
Rewatch is ReScript's build system written in Rust. It provides fast incremental builds, better error messages, and improved developer experience.
rewatch/src/
├── build/ # Core build system logic
│ ├── build_types.rs # Core data structures (BuildState, Module, etc.)
│ ├── compile.rs # Compilation logic and bsc argument generation
│ ├── parse.rs # AST generation and parser argument handling
│ ├── packages.rs # Package discovery and dependency resolution
│ ├── deps.rs # Dependency analysis and module graph
│ ├── clean.rs # Build artifact cleanup
│ └── logs.rs # Build logging and error reporting
├── cli.rs # Command-line interface definitions
├── config.rs # rescript.json configuration parsing
├── watcher.rs # File watching and incremental builds
└── main.rs # Application entry point
-
Initialization (
build::initialize_build)- Parse
rescript.jsonconfiguration - Discover packages and dependencies
- Set up compiler information
- Create initial
BuildState
- Parse
-
AST Generation (
build::parse)- Generate AST files using
bsc -bs-ast - Handle PPX transformations
- Process JSX
- Generate AST files using
-
Dependency Analysis (
build::deps)- Analyze module dependencies from AST files
- Build dependency graph
- Detect circular dependencies
-
Compilation (
build::compile)- Generate
bsccompiler arguments - Compile modules in dependency order
- Handle warnings and errors
- Generate JavaScript output
- Generate
-
Incremental Updates (
watcher.rs)- Watch for file changes
- Determine dirty modules
- Recompile only affected modules
- CLI Arguments: Add to
cli.rsinBuildArgsandWatchArgs - Configuration: Extend
config.rsfor newrescript.jsonfields - Build Logic: Modify appropriate
build/*.rsmodules - Thread Parameters: Pass new parameters through the build system chain
- Add Tests: Include unit tests for new functionality
-
Parameter Threading: New CLI flags need to be passed through:
main.rs→build::build()→initialize_build()→BuildStatemain.rs→watcher::start()→async_watch()→initialize_build()
-
Configuration Precedence: Command-line flags override
rescript.jsonconfig -
Error Handling: Use
anyhow::Resultfor error propagation -
Logging: Use
log::debug!for development debugging
# Run rewatch tests (from project root)
cargo test --manifest-path rewatch/Cargo.toml
# Test specific functionality
cargo test --manifest-path rewatch/Cargo.toml config::tests::test_get_warning_args
# Run clippy for code quality
cargo clippy --manifest-path rewatch/Cargo.toml --all-targets --all-features
# Check formatting
cargo fmt --check --manifest-path rewatch/Cargo.toml
# Build rewatch
cargo build --manifest-path rewatch/Cargo.toml --release
# Or use the Makefile shortcuts
make rewatch # Build rewatch
make test-rewatch # Run integration testsNote: The rewatch project is located in the rewatch/ directory with its own Cargo.toml file. All cargo commands should be run from the project root using the --manifest-path rewatch/Cargo.toml flag, as shown in the CI workflow.
Integration Tests: The make test-rewatch command runs bash-based integration tests located in rewatch/tests/suite.sh. These tests use the rewatch/testrepo/ directory as a test workspace with various package configurations to verify rewatch's behavior across different scenarios.
Running Individual Integration Tests: You can run individual test scripts directly by setting up the environment manually:
cd rewatch/tests
export REWATCH_EXECUTABLE="$(realpath ../target/debug/rescript)"
eval $(node ./get_bin_paths.js)
export RESCRIPT_BSC_EXE
export RESCRIPT_RUNTIME
source ./utils.sh
bash ./watch/06-watch-missing-source-folder.shThis is useful for iterating on a specific test without running the full suite.
- Build State: Use
log::debug!to inspectBuildStatecontents - Compiler Args: Check generated
bscarguments incompile.rs - Dependencies: Inspect module dependency graph in
deps.rs - File Watching: Monitor file change events in
watcher.rs
When running the rewatch binary directly (via cargo run or the compiled binary) during development, you need to set environment variables to point to the local compiler and runtime. Otherwise, rewatch will try to use the installed versions:
# Set the compiler executable path
export RESCRIPT_BSC_EXE=$(realpath _build/default/compiler/bsc/rescript_compiler_main.exe)
# Set the runtime path
export RESCRIPT_RUNTIME=$(realpath packages/@rescript/runtime)
# Now you can run rewatch directly
cargo run --manifest-path rewatch/Cargo.toml -- buildNote that the dev binary is ./rewatch/target/debug/rescript, not rewatch. The binary name is rescript because that's the package name in Cargo.toml.
This is useful when testing rewatch changes against local compiler modifications without running a full make build cycle.
Use -v for info-level logging or -vv for debug-level logging (e.g., to see which folders are being watched in watch mode):
cargo run --manifest-path rewatch/Cargo.toml -- -vv watch <folder>- Incremental Builds: Only recompile dirty modules
- Parallel Compilation: Use
rayonfor parallel processing - Memory Usage: Be mindful of
BuildStatesize in large projects - File I/O: Minimize file system operations
When clippy suggests refactoring that could impact performance, consider the trade-offs:
-
Parameter Structs vs Many Arguments: While clippy prefers parameter structs for functions with many arguments, sometimes the added complexity isn't worth it. Use
#[allow(clippy::too_many_arguments)]for functions that legitimately need many parameters and where a struct would add unnecessary complexity. -
Cloning vs Borrowing: Sometimes cloning is necessary due to Rust's borrow checker rules. If the clone is:
- Small and one-time (e.g.,
Vec<String>with few elements) - Necessary for correct ownership semantics
- Not in a hot path
Then accept the clone rather than over-engineering the solution.
- Small and one-time (e.g.,
-
When to Optimize: Profile before optimizing. Most "performance concerns" in build systems are negligible compared to actual compilation time.
-
Avoid Unnecessary Type Conversions: When threading parameters through multiple function calls, use consistent types (e.g.,
Stringthroughout) rather than converting betweenStringand&strat each boundary. This eliminates unnecessary allocations and conversions.
- Add to
BuildArgsandWatchArgsincli.rs - Update
From<BuildArgs> for WatchArgsimplementation - Pass through
main.rsto build functions - Thread through build system to where it's needed
- Add unit tests for the new functionality
- Update
compiler_args()inbuild/compile.rs - Consider both parsing and compilation phases
- Handle precedence between CLI flags and config
- Test with various
rescript.jsonconfigurations
- Use
packages.rsfor package discovery - Update
deps.rsfor dependency analysis - Handle both local and external dependencies
- Consider dev dependencies vs regular dependencies
- Modify
watcher.rsfor file change handling - Update
AsyncWatchArgsfor new parameters - Handle different file types (
.res,.resi, etc.) - Consider performance impact of watching many files
sleepis fragile — Prefer polling (e.g.,wait_for_file) over fixed sleeps. CI runners are slower than local machines.exit_watcheris async — It only signals the watcher to stop (removes the lock file), it doesn't wait for the process to exit. Avoid triggering config-change events before exiting, as the watcher may start a concurrent rebuild.sed -idiffers across platforms — macOS requiressed -i '' ..., Linux does not. Use thereplace/normalize_pathshelpers fromrewatch/tests/utils.shinstead of rawsed.