Skip to content

Commit 4f65236

Browse files
jfrolichclaude
andauthored
Rewatch: feature-gated source directories (#8379)
* Rewatch: feature-gated source directories Add a `feature` tag on source entries so a package can ship optional slices of its source tree that consumers opt into. The new top-level `features` map declares transitive implications, and `dependencies` / `dev-dependencies` accept an object form `{name, features}` so a consumer can restrict which features of a dep get built. On the CLI, `rewatch build --features=a,b` (and the same on `watch`) restricts the current package; dependencies inherit from consumer declarations in rescript.json (union across consumers). Omitting the flag keeps all features active. Filtering runs inside `packages::make` before source files are discovered, so the watcher automatically skips disabled directories and the existing diff-based cleanup removes orphan artifacts when a feature is toggled off. `clean` intentionally ignores `--features` so it always wipes the full tree. Cycles in the `features` map are rejected at load time with a message naming the participants. Empty `--features=` is rejected with guidance to omit the flag instead. Includes unit tests for parsing, closure resolution, cycle detection, and per-package active-set computation; six bash integration tests covering CLI restriction, transitive expansion, artifact cleanup on toggle, cycle errors, and empty-flag rejection. Documentation in rewatch/Features.md and a row added to rewatch/CompilerConfigurationSpec.md. Signed-off-by: Jaap Frolich <jaap@tella.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Jaap Frolich <jfrolich@gmail.com> * Rewatch: skip dev-dependency feature requests in --prod compute_active_features scanned both dependencies and dev-dependencies on every consumer regardless of the active build mode. In --prod, a dep reached via dependencies with a restricted feature list could still have a shorthand dev-dependencies entry on the same consumer flip any_all_request = true and force all features active — pulling in feature-gated code that --prod is supposed to exclude. Match read_dependencies's rule: dev-dependency edges only contribute to a dep's active feature set when the consumer is local and we're not in --prod. Regression test covers both modes. Signed-off-by: Jaap Frolich <jaap@tella.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Jaap Frolich <jfrolich@gmail.com> * Rewatch: honour explicit empty features list on dependencies compute_active_features conflated "no consumer edge found" with "consumer requested empty". A qualified dependency with `"features": []` would land in the same state as an unreached dep (requested set empty, no `any_all_request`) and trip the fallback that forced all features active. That made it impossible to express an untagged-only dependency build. Track `saw_consumer_entry` explicitly. The all-features fallback now fires only when no consumer edge was observed; an empty requested set from an explicit `"features": []` is honoured as "no feature-gated dirs". Regression test covers the explicit-empty case. Docs updated. Signed-off-by: Jaap Frolich <jaap@tella.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Jaap Frolich <jfrolich@gmail.com> --------- Signed-off-by: Jaap Frolich <jfrolich@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 49a7238 commit 4f65236

29 files changed

Lines changed: 1411 additions & 56 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#### :rocket: New Feature
2424

2525
- Rewatch: add `--prod` flag to `build`, `watch`, and `clean` to skip dev-dependencies and dev sources (`"type": "dev"`), enabling builds in environments where dev packages aren't installed (e.g. after `pnpm install --prod`). https://github.com/rescript-lang/rescript/pull/8347
26+
- Rewatch: feature-gated source directories. Tag a source entry with `"feature": "<name>"` and select with `--features a,b` (or per-dep in `dependencies` / `dev-dependencies`) to include optional slices of a package's source tree at build time. Top-level `features` map supports transitive implications. https://github.com/rescript-lang/rescript/pull/8379
2627
- Add `Dict.assignMany`, `Dict.concat`, `Dict.concatMany`, `Dict.concatAll`, `Array.concatAll` to the stdlib. https://github.com/rescript-lang/rescript/pull/8364
2728
- Implement `for...of` and `for await...of` loops. https://github.com/rescript-lang/rescript/pull/7887
2829
- Add support for dict spreads: `dict{...foo, "bar": 2, ...qux}`. https://github.com/rescript-lang/rescript/pull/8369

rewatch/CompilerConfigurationSpec.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ This document contains a list of all config parameters with remarks, and whether
1313
| sources | array of Source | | [x] |
1414
| ignored-dirs | array of string | | [_] |
1515
| dependencies | array of string | | [x] |
16+
| dependencies | array of Dependency | See [Features.md](./Features.md). rewatch extension. | [x] |
1617
| dev-dependencies | array of string | | [x] |
18+
| dev-dependencies | array of Dependency | See [Features.md](./Features.md). rewatch extension. | [x] |
19+
| features | map of string to array | See [Features.md](./Features.md). rewatch extension. | [x] |
1720
| generators | array of Rule-Generator | | [_] |
1821
| cut-generators | boolean | | [_] |
1922
| jsx | JSX | | [x] |
@@ -34,10 +37,11 @@ This document contains a list of all config parameters with remarks, and whether
3437

3538
### Source
3639

37-
| Parameter | JSON type | Remark | Implemented? |
38-
| ---------------- | ------------------------ | ------ | :----------: |
39-
| dir | string | | [x] |
40-
| type | "dev" | | [x] |
40+
| Parameter | JSON type | Remark | Implemented? |
41+
| ---------------- | ------------------------ | ------------------------------------------------------- | :----------: |
42+
| dir | string | | [x] |
43+
| type | "dev" | | [x] |
44+
| feature | string | See [Features.md](./Features.md). rewatch extension. | [x] |
4145
| files | array of string | | [_] |
4246
| files | File-Object | | [_] |
4347
| generators | array of Build-Generator | | [_] |

rewatch/Features.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Features
2+
3+
Features let a package declare optional parts of its source tree that can be included or excluded at build time. Use them to ship a library with optional backends, experimental modules, or platform-specific code without paying the compile cost when a consumer doesn't need them.
4+
5+
Features are a `rewatch` extension and are not part of the legacy `bsb` build-configuration spec.
6+
7+
## Tagging a source directory
8+
9+
Add a `feature` property to any entry in `sources`. The directory is included in the build only when the feature is active for that package.
10+
11+
```json
12+
{
13+
"name": "@example/lib",
14+
"sources": [
15+
{ "dir": "src" },
16+
{ "dir": "src-native", "feature": "native" },
17+
{ "dir": "src-experimental", "feature": "experimental" }
18+
]
19+
}
20+
```
21+
22+
Untagged source directories (no `feature`) are always compiled. A tagged source's `feature` cascades down into nested `subdirs`: a child that doesn't declare its own `feature` inherits the parent's.
23+
24+
## Declaring feature relationships
25+
26+
A top-level `features` map declares names and optional implications. Listing a feature here is only required when you want one feature to imply another; leaf features can stay undeclared and still work as source-dir tags.
27+
28+
```json
29+
{
30+
"features": {
31+
"full": ["native", "experimental"]
32+
}
33+
}
34+
```
35+
36+
With the above, requesting `full` transitively enables `native` and `experimental`. Cycles (e.g. `a -> b -> a`) are rejected at build time with a clear error.
37+
38+
## Selecting features on the command line
39+
40+
When you run `rewatch build` or `rewatch watch`, pass `--features` to restrict compilation to a specific set. Without the flag, every feature is active and the whole source tree compiles:
41+
42+
```
43+
rewatch build # all features active (default)
44+
rewatch build --features native # only untagged + native
45+
rewatch build --features native,full # multiple features; also expands `full`
46+
```
47+
48+
The CLI flag applies only to the **current package** — the one you're building from. It does not flow down to dependencies; each dependency's active feature set comes from its consumer declarations (see below).
49+
50+
Passing an empty value (`--features ""` or `--features ,`) is rejected. Omit the flag to mean "all features".
51+
52+
## Restricting a dependency's features
53+
54+
When consuming another ReScript package that uses features, switch the entry in `dependencies` or `dev-dependencies` from the shorthand string to an object form and list which features you want:
55+
56+
```json
57+
{
58+
"dependencies": [
59+
"@plain/dep",
60+
{ "name": "@example/lib", "features": ["native"] }
61+
]
62+
}
63+
```
64+
65+
Rules:
66+
67+
- **Shorthand (`"@plain/dep"`)** — the consumer wants every feature of that dependency. This is the existing behavior; nothing changes for configs that don't opt into features.
68+
- **Object with `features`** — the consumer restricts the dependency to the listed features (and whatever they transitively imply through the dependency's own `features` map). An explicit empty list (`"features": []`) means "only untagged source dirs, no feature-gated code".
69+
- **Object without `features`** — equivalent to the shorthand. All features active.
70+
71+
When the same dependency is referenced by multiple consumers with different feature sets, the union of requests wins. If any consumer asks for all features, the dependency builds with all of its features. Features are always additive — enabling more features never removes modules, so the union is always safe.
72+
73+
## Interaction with other flags
74+
75+
- **`type: "dev"` and `--prod`** are orthogonal to features. A source directory may declare both `type: "dev"` and a `feature`; it will only build when both filters pass (not in `--prod`, and the feature is active).
76+
- **`rewatch clean`** ignores `--features` and always cleans the full set of build artifacts across every feature-gated directory. This keeps `clean` predictable regardless of which features happen to be active.
77+
78+
## How incremental builds handle feature changes
79+
80+
Toggling a feature off between builds removes its source files from the build's view. The next `rewatch build` sees the shrunken file set and cleans up the corresponding artifacts (`.mjs`, `.cmj`, etc.) through the same diff mechanism that handles deleted source files.
81+
82+
For `rewatch watch`, a change to `features` in `rescript.json` triggers a full rebuild that recomputes the active set and re-registers watches on the active source directories. The CLI `--features` flag is evaluated once at watcher start; to change it you must restart the watcher.
83+
84+
## Validating feature names
85+
86+
- Unknown feature names in CLI input or source tags are accepted as leaf features (they simply match nothing unless a source directory is tagged with that exact name).
87+
- Cycles in the top-level `features` map are a hard error that names the cycle participants.
88+
89+
## Example
90+
91+
```json
92+
{
93+
"name": "@example/lib",
94+
"sources": [
95+
{ "dir": "src" },
96+
{ "dir": "src-native", "feature": "native" },
97+
{ "dir": "src-web", "feature": "web" },
98+
{ "dir": "src-experimental", "feature": "experimental" }
99+
],
100+
"features": {
101+
"all-backends": ["native", "web"]
102+
},
103+
"dependencies": [
104+
"@plain/dep",
105+
{ "name": "@other/heavy", "features": ["native"] }
106+
]
107+
}
108+
```
109+
110+
- `rewatch build` at `@example/lib` — compiles every source dir; `@other/heavy` builds with just its `native` feature because that's all the consumer requested; `@plain/dep` builds with all of its features.
111+
- `rewatch build --features all-backends` — compiles `src`, `src-native`, `src-web`; skips `src-experimental`.
112+
- `rewatch build --features experimental` — compiles `src`, `src-experimental`; skips the backends.

rewatch/src/build.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ pub fn get_compiler_info(project_context: &ProjectContext) -> Result<CompilerInf
165165
})
166166
}
167167

168+
#[allow(clippy::too_many_arguments)]
168169
pub fn initialize_build(
169170
default_timing: Option<Duration>,
170171
filter: &Option<regex::Regex>,
@@ -173,12 +174,13 @@ pub fn initialize_build(
173174
plain_output: bool,
174175
warn_error: Option<String>,
175176
prod: bool,
177+
features: Option<Vec<String>>,
176178
) -> Result<BuildCommandState> {
177179
let project_context = ProjectContext::new(path)?;
178180
let compiler = get_compiler_info(&project_context)?;
179181

180182
let timing_clean_start = Instant::now();
181-
let packages = packages::make(filter, &project_context, show_progress, prod)?;
183+
let packages = packages::make(filter, &project_context, show_progress, prod, features.as_ref())?;
182184

183185
let compiler_check = verify_compiler_info(&packages, &compiler);
184186

@@ -192,6 +194,7 @@ pub fn initialize_build(
192194
packages,
193195
compiler,
194196
warn_error,
197+
features,
195198
);
196199
packages::parse_packages(&mut build_state)?;
197200

@@ -589,6 +592,7 @@ pub fn build(
589592
plain_output: bool,
590593
warn_error: Option<String>,
591594
prod: bool,
595+
features: Option<Vec<String>>,
592596
) -> Result<BuildCommandState> {
593597
let default_timing: Option<std::time::Duration> = if no_timing {
594598
Some(std::time::Duration::new(0.0 as u64, 0.0 as u32))
@@ -604,6 +608,7 @@ pub fn build(
604608
plain_output,
605609
warn_error,
606610
prod,
611+
features,
607612
)
608613
.with_context(|| "Could not initialize build")?;
609614

rewatch/src/build/build_types.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ pub struct BuildCommandState {
126126
pub build_state: BuildState,
127127
// Command-line --warn-error flag override (takes precedence over rescript.json config)
128128
pub warn_error_override: Option<String>,
129+
// Command-line --features override. `None` means all features are active; `Some(list)`
130+
// restricts the root package to those features (and whatever they transitively imply).
131+
pub features: Option<Vec<String>>,
129132
}
130133

131134
#[derive(Debug, Clone)]
@@ -177,18 +180,24 @@ impl BuildCommandState {
177180
packages: AHashMap<String, Package>,
178181
compiler: CompilerInfo,
179182
warn_error_override: Option<String>,
183+
features: Option<Vec<String>>,
180184
) -> Self {
181185
Self {
182186
root_folder,
183187
build_state: BuildState::new(project_context, packages, compiler),
184188
warn_error_override,
189+
features,
185190
}
186191
}
187192

188193
pub fn get_warn_error_override(&self) -> Option<String> {
189194
self.warn_error_override.clone()
190195
}
191196

197+
pub fn get_features(&self) -> Option<Vec<String>> {
198+
self.features.clone()
199+
}
200+
192201
pub fn module_name_package_pairs(&self) -> Vec<(String, String)> {
193202
self.build_state
194203
.modules

rewatch/src/build/clean.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,10 @@ pub fn cleanup_after_build(build_state: &BuildCommandState) {
335335
pub fn clean(path: &Path, show_progress: bool, plain_output: bool, prod: bool) -> Result<()> {
336336
let project_context = ProjectContext::new(path)?;
337337
let compiler_info = build::get_compiler_info(&project_context)?;
338-
let packages = packages::make(&None, &project_context, show_progress, prod)?;
338+
// `clean` always acts on the full set of source directories regardless of which features are
339+
// active. We explicitly pass `None` so every tagged source folder is included and its
340+
// artifacts can be removed, even for features the user hasn't enabled for this build.
341+
let packages = packages::make(&None, &project_context, show_progress, prod, None)?;
339342

340343
let timing_clean_compiler_assets = Instant::now();
341344
if !plain_output && show_progress {

rewatch/src/build/compile.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,10 @@ pub fn compiler_args(
693693
.dependencies
694694
.iter()
695695
.flatten()
696-
.filter_map(|name| pkgs.get(name).map(|pkg| (name.clone(), pkg.path.clone())))
696+
.filter_map(|dep| {
697+
let name = dep.name();
698+
pkgs.get(name).map(|pkg| (name.to_string(), pkg.path.clone()))
699+
})
697700
.collect::<Vec<_>>()
698701
});
699702
resolved.unwrap_or_default()
@@ -816,20 +819,16 @@ fn get_dependency_paths(
816819
packages: &Option<&AHashMap<String, packages::Package>>,
817820
is_file_type_dev: bool,
818821
) -> Vec<String> {
819-
let normal_deps = config
820-
.dependencies
821-
.clone()
822-
.unwrap_or_default()
822+
let normal_deps: Vec<DependentPackage> = config
823+
.get_dependency_names()
823824
.into_iter()
824825
.map(DependentPackage::Normal)
825826
.collect();
826827

827828
// We can only access dev dependencies for source_files that are marked as "type":"dev"
828-
let dev_deps = if is_file_type_dev {
829+
let dev_deps: Vec<DependentPackage> = if is_file_type_dev {
829830
config
830-
.dev_dependencies
831-
.clone()
832-
.unwrap_or_default()
831+
.get_dev_dependency_names()
833832
.into_iter()
834833
.map(DependentPackage::Dev)
835834
.collect()

rewatch/src/build/deps.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,9 @@ fn get_dep_modules(
3737
// Get the list of allowed dependency packages for this package
3838
let allowed_dependencies: AHashSet<String> = package
3939
.config
40-
.dependencies
41-
.as_ref()
42-
.unwrap_or(&vec![])
43-
.iter()
44-
.chain(package.config.dev_dependencies.as_ref().unwrap_or(&vec![]).iter())
45-
.cloned()
40+
.get_dependency_names()
41+
.into_iter()
42+
.chain(package.config.get_dev_dependency_names())
4643
.collect();
4744

4845
deps.iter()

0 commit comments

Comments
 (0)