From 00d6bff2071bed597d1dbd57ee365a55d9991e28 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 28 Sep 2026 08:04:56 +0200 Subject: [PATCH 1/7] Enable selective cross-module inlining for Option helpers Signed-off-by: Christoph Knittel --- compiler/core/js_cmj_format.ml | 31 +++++++- compiler/core/js_cmj_format.mli | 6 +- compiler/core/lam_analysis.ml | 2 +- compiler/core/lam_stats_export.ml | 13 ++++ compiler/ml/lambda.ml | 1 + compiler/ml/lambda.mli | 1 + compiler/ml/printlambda.ml | 1 + compiler/ml/translattribute.ml | 13 ++-- packages/@rescript/runtime/Stdlib_Option.res | 3 + rewatch/src/build/clean.rs | 4 +- rewatch/src/build/compile.rs | 13 +++- .../build_tests/cross_module_inline/input.js | 42 ++++++++++ .../cross_module_inline/rescript.json | 5 ++ .../build_tests/cross_module_inline/src/A.res | 2 + .../build_tests/cross_module_inline/src/B.res | 1 + .../warning_47_attribute_payload.res.expected | 2 +- .../tests/src/cross_module_inline_export.mjs | 16 ++++ .../tests/src/cross_module_inline_export.res | 4 + .../src/cross_module_inline_opt_in_test.mjs | 76 +++++++++++++++++++ .../src/cross_module_inline_opt_in_test.res | 35 +++++++++ tests/tests/src/int_overflow_test.mjs | 12 ++- tests/tests/src/stdlib/Stdlib_ObjectTests.mjs | 13 +++- tests/tests/src/stdlib/Stdlib_TempTests.mjs | 10 +-- 23 files changed, 277 insertions(+), 29 deletions(-) create mode 100644 tests/build_tests/cross_module_inline/input.js create mode 100644 tests/build_tests/cross_module_inline/rescript.json create mode 100644 tests/build_tests/cross_module_inline/src/A.res create mode 100644 tests/build_tests/cross_module_inline/src/B.res create mode 100644 tests/tests/src/cross_module_inline_export.mjs create mode 100644 tests/tests/src/cross_module_inline_export.res create mode 100644 tests/tests/src/cross_module_inline_opt_in_test.mjs create mode 100644 tests/tests/src/cross_module_inline_opt_in_test.res diff --git a/compiler/core/js_cmj_format.ml b/compiler/core/js_cmj_format.ml index 85253182fa2..ee0962b442d 100644 --- a/compiler/core/js_cmj_format.ml +++ b/compiler/core/js_cmj_format.ml @@ -97,14 +97,40 @@ let to_file name ~check_exists (v : t) = let s = Marshal.to_string v [] in let cur_digest = Digest.string s in let header = cur_digest in - if not (check_exists && for_sure_not_changed name header) then + if not (check_exists && for_sure_not_changed name header) then ( let oc = open_out_bin (Compiler_request_state.resolve_path name) in Fun.protect ~finally:(fun () -> close_out_noerr oc) (fun () -> output_string oc header; output_string oc s; - close_out oc) + close_out oc)); + (* Rewatch normally rebuilds dependents only when their dependency's .cmi + changes. An exported inline body can change without changing its type, so + give Rewatch a small digest to track for modules that export one. *) + let inline_digest_file = name ^ ".inline" in + let has_inline_export = + Array.exists + (fun (value : keyed_cmj_value) -> + match value.persistent_closed_lambda with + | Some (Lfunction f) -> f.attr.inline = Cross_module_inline + | _ -> false) + v.values + in + if has_inline_export then ( + if not (check_exists && for_sure_not_changed inline_digest_file cur_digest) + then ( + let oc = + open_out_bin (Compiler_request_state.resolve_path inline_digest_file) + in + Fun.protect + ~finally:(fun () -> close_out_noerr oc) + (fun () -> + output_string oc cur_digest; + close_out oc))) + else + let path = Compiler_request_state.resolve_path inline_digest_file in + if Sys.file_exists path then Sys.remove path let key_comp a b = Ext_string.compare a b.name @@ -118,6 +144,7 @@ let get_result mid_val = (Const_js_null | Const_js_undefined _ | Const_js_true | Const_js_false)) | None -> mid_val + | Some (Lfunction f) when f.attr.inline = Cross_module_inline -> mid_val | Some _ -> if !((Js_config.current ()).cross_module_inline) then mid_val else {mid_val with persistent_closed_lambda = None} diff --git a/compiler/core/js_cmj_format.mli b/compiler/core/js_cmj_format.mli index 937f5257778..439afe49839 100644 --- a/compiler/core/js_cmj_format.mli +++ b/compiler/core/js_cmj_format.mli @@ -81,9 +81,9 @@ val single_na : arity val from_file : string -> t -(* - Note writing the file if its content is not changed -*) +(* Write the .cmj and, when it exports a cross-module inline function, a + .cmj.inline digest used by Rewatch to invalidate callers. Unchanged files + are not rewritten when [check_exists] is true. *) val to_file : string -> check_exists:bool -> t -> unit type path = string diff --git a/compiler/core/lam_analysis.ml b/compiler/core/lam_analysis.ml index de610d34089..358465641ab 100644 --- a/compiler/core/lam_analysis.ml +++ b/compiler/core/lam_analysis.ml @@ -277,7 +277,7 @@ let lfunction_can_be_inlined (lfunction : Lambda.lfunction) = (** Hints to inlining *) let ok_to_inline_fun_when_app (m : Lambda.lfunction) (args : Lambda.t list) = match m.attr.inline with - | Always_inline -> true + | Always_inline | Cross_module_inline -> true | Never_inline -> false | Default_inline -> ( match m with diff --git a/compiler/core/lam_stats_export.ml b/compiler/core/lam_stats_export.ml index e3051f300f8..651d8bbff3b 100644 --- a/compiler/core/lam_stats_export.ml +++ b/compiler/core/lam_stats_export.ml @@ -58,6 +58,19 @@ let values_of_export (meta : Lam_stats.t) (export_map : Lambda.t Map_ident.t) : | Const_js_false )) | None -> optlam + | Some + (Lfunction + ({attr = {inline = Cross_module_inline}; loc} as lfunction) as + lambda) -> + if not (Lam_analysis.lfunction_can_be_inlined lfunction) then + Location.raise_errorf ~loc + "@inline(crossModule) cannot be used on an async or directed \ + function" + else if not (Lam_closure.is_closed lambda) then + Location.raise_errorf ~loc + "@inline(crossModule) requires a function without captured local \ + values" + else optlam | Some lambda -> if not !((Js_config.current ()).cross_module_inline) then None else if diff --git a/compiler/ml/lambda.ml b/compiler/ml/lambda.ml index 5e9d529f7c0..01556977676 100644 --- a/compiler/ml/lambda.ml +++ b/compiler/ml/lambda.ml @@ -328,6 +328,7 @@ type inline_attribute = | Always_inline (* [@inline] or [@inline always] *) | Never_inline (* [@inline never] *) | Default_inline (* no [@inline] attribute *) + | Cross_module_inline (* [@inline(crossModule)] *) type let_kind = Strict | Alias | StrictOpt | Variable diff --git a/compiler/ml/lambda.mli b/compiler/ml/lambda.mli index f0ed1cac2c8..723c0b1fa2e 100644 --- a/compiler/ml/lambda.mli +++ b/compiler/ml/lambda.mli @@ -330,6 +330,7 @@ type inline_attribute = | Always_inline (* [@inline] or [@inline always] *) | Never_inline (* [@inline never] *) | Default_inline (* no [@inline] attribute *) + | Cross_module_inline (* [@inline(crossModule)] *) type let_kind = Strict | Alias | StrictOpt | Variable (* Meaning of kinds for let x = e in e': diff --git a/compiler/ml/printlambda.ml b/compiler/ml/printlambda.ml index 0457903c6a6..401c45147d9 100644 --- a/compiler/ml/printlambda.ml +++ b/compiler/ml/printlambda.ml @@ -240,6 +240,7 @@ let function_attribute ppf {inline; is_a_functor; return_unit} = match inline with | Default_inline -> () | Always_inline -> fprintf ppf "always_inline@ " + | Cross_module_inline -> fprintf ppf "cross_module_inline@ " | Never_inline -> fprintf ppf "never_inline@ " let rec lam ppf = function diff --git a/compiler/ml/translattribute.ml b/compiler/ml/translattribute.ml index bceb8f54b28..2f9d23d3bd5 100644 --- a/compiler/ml/translattribute.ml +++ b/compiler/ml/translattribute.ml @@ -34,11 +34,12 @@ let parse_inline_attribute (attr : t option) : Lambda.inline_attribute = | Some ({txt; loc}, payload) -> ( let open Parsetree in (* the 'inline' attribute can be used as - [@inline], [@inline never] or [@inline always]. + [@inline], [@inline never], [@inline always] or + [@inline(crossModule)]. [@inline] is equivalent to [@inline always] *) let warning txt = Warnings.Attribute_payload - (txt, "It must be either empty, 'always' or 'never'") + (txt, "It must be empty, 'always', 'never' or 'crossModule'") in match payload with | PStr [] -> Always_inline @@ -46,6 +47,7 @@ let parse_inline_attribute (attr : t option) : Lambda.inline_attribute = match (Ast_payload.unwrap_braces expression).pexp_desc with | Pexp_ident {txt = Longident.Lident "never"} -> Never_inline | Pexp_ident {txt = Longident.Lident "always"} -> Always_inline + | Pexp_ident {txt = Longident.Lident "crossModule"} -> Cross_module_inline | _ -> Location.prerr_warning loc (warning txt); Default_inline) @@ -62,14 +64,11 @@ let add_inline_attribute (expr : Lambda.t) loc attributes = | Lfunction ({attr} as funct), inline -> (match attr.inline with | Default_inline -> () - | Always_inline | Never_inline -> + | Always_inline | Cross_module_inline | Never_inline -> Location.prerr_warning loc (Warnings.Duplicated_attribute "inline")); let attr = {attr with inline} in Lambda.function_ ~loc:funct.loc ~attr ~params:funct.params ~body:funct.body - | expr, Always_inline -> - Location.prerr_warning loc (Warnings.Misplaced_attribute "inline"); - expr - | expr, Never_inline -> + | expr, (Always_inline | Cross_module_inline | Never_inline) -> Location.prerr_warning loc (Warnings.Misplaced_attribute "inline"); expr diff --git a/packages/@rescript/runtime/Stdlib_Option.res b/packages/@rescript/runtime/Stdlib_Option.res index 660f5a60a30..6c7bb7565a4 100644 --- a/packages/@rescript/runtime/Stdlib_Option.res +++ b/packages/@rescript/runtime/Stdlib_Option.res @@ -11,6 +11,7 @@ let filter = (opt, p) => | _ => None } +@inline(crossModule) let forEach = (opt, f) => switch opt { | Some(x) => f(x) @@ -37,12 +38,14 @@ let mapOr = (opt, default, f) => | None => default } +@inline(crossModule) let map = (opt, f) => switch opt { | Some(x) => Some(f(x)) | None => None } +@inline(crossModule) let flatMap = (opt, f) => switch opt { | Some(x) => f(x) diff --git a/rewatch/src/build/clean.rs b/rewatch/src/build/clean.rs index a5e620b7d91..4223477f9bb 100644 --- a/rewatch/src/build/clean.rs +++ b/rewatch/src/build/clean.rs @@ -55,14 +55,14 @@ fn remove_compile_asset(package: &packages::Package, source_file: &Path, extensi package, &package.namespace, source_file, - extension, + &format!(".{extension}"), )); } fn remove_compile_assets(package: &packages::Package, source_file: &Path) { // optimization // only issue cmti if there is an interfacce file - for extension in &["cmj", "cmi", "cmt", "cmti"] { + for extension in &["cmj", "cmj.inline", "cmi", "cmt", "cmti"] { remove_compile_asset(package, source_file, extension); } } diff --git a/rewatch/src/build/compile.rs b/rewatch/src/build/compile.rs index c4677632cf1..353b7ed8cf4 100644 --- a/rewatch/src/build/compile.rs +++ b/rewatch/src/build/compile.rs @@ -255,6 +255,13 @@ fn compile_one( "cmi", ); let cmi_digest = helpers::compute_file_hash(Path::new(&cmi_path)); + let inline_digest_path = helpers::get_bs_compiler_asset( + package, + &package.namespace, + &source_file.implementation.path, + ".cmj.inline", + ); + let inline_digest = helpers::compute_file_hash(Path::new(&inline_digest_path)); let interface_result = source_file.interface.as_ref().map(|iface| { compile_file( @@ -275,6 +282,7 @@ fn compile_one( warn_error_override, ); let cmi_digest_after = helpers::compute_file_hash(Path::new(&cmi_path)); + let inline_digest_after = helpers::compute_file_hash(Path::new(&inline_digest_path)); // If the cmi is byte-for-byte unchanged, downstream modules can // short-circuit — we check both interface and implementation @@ -284,12 +292,15 @@ fn compile_one( (cmi_digest, cmi_digest_after), (Some(a), Some(b)) if a == b ); + // A marked inline body can change without changing the CMI. The + // compiler emits a digest only for modules that export such a body. + let is_clean_inline = inline_digest == inline_digest_after; CompletionMsg { module_name: module_name.to_string(), result, interface_result, - is_clean: is_clean_cmi, + is_clean: is_clean_cmi && is_clean_inline, is_compiled: true, } } diff --git a/tests/build_tests/cross_module_inline/input.js b/tests/build_tests/cross_module_inline/input.js new file mode 100644 index 00000000000..144c52842c5 --- /dev/null +++ b/tests/build_tests/cross_module_inline/input.js @@ -0,0 +1,42 @@ +// @ts-check + +import * as assert from "node:assert"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { setup } from "#dev/process"; + +const { execBuildOrThrow, execClean } = setup(import.meta.dirname); +const source = path.join(import.meta.dirname, "src", "A.res"); +const output = path.join(import.meta.dirname, "src", "B.js"); +const inlineDigest = path.join( + import.meta.dirname, + "lib", + "bs", + "src", + "A.cmj.inline", +); +const original = await fs.readFile(source, "utf8"); + +await execClean(); +try { + await execBuildOrThrow(); + assert.match(await fs.readFile(output, "utf8"), /let result = 2;/); + await fs.access(inlineDigest); + await execClean(); + await assert.rejects(fs.access(inlineDigest)); + await execBuildOrThrow(); + + // The public type stays the same, but the saved inline body changes. + await fs.writeFile(source, original.replace("x + 1", "x + 2")); + await execBuildOrThrow(); + assert.match(await fs.readFile(output, "utf8"), /let result = 3;/); + + // Removing the marker removes the digest and restores a normal call. + await fs.writeFile(source, original.replace("@inline(crossModule)\n", "")); + await execBuildOrThrow(); + assert.match(await fs.readFile(output, "utf8"), /A\.bump\(1\)/); + await assert.rejects(fs.access(inlineDigest)); +} finally { + await fs.writeFile(source, original); + await execClean(); +} diff --git a/tests/build_tests/cross_module_inline/rescript.json b/tests/build_tests/cross_module_inline/rescript.json new file mode 100644 index 00000000000..ba764d8f55a --- /dev/null +++ b/tests/build_tests/cross_module_inline/rescript.json @@ -0,0 +1,5 @@ +{ + "name": "cross-module-inline-build-test", + "sources": [{ "dir": "src" }], + "package-specs": { "module": "commonjs", "in-source": true } +} diff --git a/tests/build_tests/cross_module_inline/src/A.res b/tests/build_tests/cross_module_inline/src/A.res new file mode 100644 index 00000000000..1224d11fc7d --- /dev/null +++ b/tests/build_tests/cross_module_inline/src/A.res @@ -0,0 +1,2 @@ +@inline(crossModule) +let bump = x => x + 1 diff --git a/tests/build_tests/cross_module_inline/src/B.res b/tests/build_tests/cross_module_inline/src/B.res new file mode 100644 index 00000000000..ffe66e169ff --- /dev/null +++ b/tests/build_tests/cross_module_inline/src/B.res @@ -0,0 +1 @@ +let result = A.bump(1) diff --git a/tests/build_tests/super_errors/expected/warning_47_attribute_payload.res.expected b/tests/build_tests/super_errors/expected/warning_47_attribute_payload.res.expected index c5bd7a58c4c..95d9436707e 100644 --- a/tests/build_tests/super_errors/expected/warning_47_attribute_payload.res.expected +++ b/tests/build_tests/super_errors/expected/warning_47_attribute_payload.res.expected @@ -7,4 +7,4 @@ 3 │ illegal payload for attribute @inline. -It must be either empty, 'always' or 'never' \ No newline at end of file +It must be empty, 'always', 'never' or 'crossModule' diff --git a/tests/tests/src/cross_module_inline_export.mjs b/tests/tests/src/cross_module_inline_export.mjs new file mode 100644 index 00000000000..cd8317327a5 --- /dev/null +++ b/tests/tests/src/cross_module_inline_export.mjs @@ -0,0 +1,16 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + + +function apply(value, f) { + return f(value); +} + +function ordinary(value, f) { + return f(value); +} + +export { + apply, + ordinary, +} +/* No side effect */ diff --git a/tests/tests/src/cross_module_inline_export.res b/tests/tests/src/cross_module_inline_export.res new file mode 100644 index 00000000000..b0de589a9f3 --- /dev/null +++ b/tests/tests/src/cross_module_inline_export.res @@ -0,0 +1,4 @@ +@inline(crossModule) +let apply = (value, f) => f(value) + +let ordinary = (value, f) => f(value) diff --git a/tests/tests/src/cross_module_inline_opt_in_test.mjs b/tests/tests/src/cross_module_inline_opt_in_test.mjs new file mode 100644 index 00000000000..9547f3661d3 --- /dev/null +++ b/tests/tests/src/cross_module_inline_opt_in_test.mjs @@ -0,0 +1,76 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + +import * as Mocha from "mocha"; +import * as Test_utils from "./test_utils.mjs"; +import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.mjs"; +import * as Cross_module_inline_export from "./cross_module_inline_export.mjs"; + +let mapped = 3; + +let flatMapped = 4; + +let nested = Primitive_option.some(undefined); + +let mappedNested = Primitive_option.some(Primitive_option.valFromOption(nested)); + +function mapValue(opt) { + if (opt !== undefined) { + return Primitive_option.some((x => x + 1 | 0)(Primitive_option.valFromOption(opt))); + } +} + +function flatMapValue(opt) { + if (opt !== undefined) { + let x = Primitive_option.valFromOption(opt); + return x + 2 | 0; + } +} + +function forEachValue(opt, f) { + if (opt !== undefined) { + return f(Primitive_option.valFromOption(opt)); + } +} + +let visited = []; + +visited.push(4); + +let fromOrdinary = Cross_module_inline_export.ordinary(3, x => x + 1 | 0); + +Mocha.describe("cross-module inline opt-in", () => { + Mocha.test("Option helpers", () => { + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 21, characters 7-14", mapped, 3); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 22, characters 7-14", flatMapped, 4); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 23, characters 7-14", mappedNested, Primitive_option.some(undefined)); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 24, characters 7-14", mapValue(2), 3); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 25, characters 7-14", mapValue(undefined), undefined); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 26, characters 7-14", flatMapValue(2), 4); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 27, characters 7-14", flatMapValue(undefined), undefined); + visited.push(5); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 29, characters 7-14", visited, [ + 4, + 5 + ]); + }); + Mocha.test("only marked exports are inlined", () => { + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 32, characters 7-14", 4, 4); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 33, characters 7-14", fromOrdinary, 4); + }); +}); + +let fromExport = 4; + +export { + mapped, + flatMapped, + nested, + mappedNested, + mapValue, + flatMapValue, + forEachValue, + visited, + fromExport, + fromOrdinary, +} +/* Not a pure module */ diff --git a/tests/tests/src/cross_module_inline_opt_in_test.res b/tests/tests/src/cross_module_inline_opt_in_test.res new file mode 100644 index 00000000000..6cc81e946dc --- /dev/null +++ b/tests/tests/src/cross_module_inline_opt_in_test.res @@ -0,0 +1,35 @@ +open Mocha +open Test_utils + +let mapped = Some(2)->Option.map(x => x + 1) +let flatMapped = Some(2)->Option.flatMap(x => Some(x + 2)) +let nested: option> = Some(None) +let mappedNested = nested->Option.map(x => x) +let mapValue = opt => opt->Option.map(x => x + 1) +let flatMapValue = opt => opt->Option.flatMap(x => Some(x + 2)) +let forEachValue = (opt, f) => opt->Option.forEach(f) + +let visited: array = [] +let _ = Some(4)->Option.forEach(x => visited->Array.push(x)) +let _ = None->Option.forEach(_ => visited->Array.push(0)) + +let fromExport = Cross_module_inline_export.apply(3, x => x + 1) +let fromOrdinary = Cross_module_inline_export.ordinary(3, x => x + 1) + +describe("cross-module inline opt-in", () => { + test("Option helpers", () => { + eq(__LOC__, mapped, Some(3)) + eq(__LOC__, flatMapped, Some(4)) + eq(__LOC__, mappedNested, Some(None)) + eq(__LOC__, mapValue(Some(2)), Some(3)) + eq(__LOC__, mapValue(None), None) + eq(__LOC__, flatMapValue(Some(2)), Some(4)) + eq(__LOC__, flatMapValue(None), None) + forEachValue(Some(5), x => visited->Array.push(x)) + eq(__LOC__, visited, [4, 5]) + }) + test("only marked exports are inlined", () => { + eq(__LOC__, fromExport, 4) + eq(__LOC__, fromOrdinary, 4) + }) +}) diff --git a/tests/tests/src/int_overflow_test.mjs b/tests/tests/src/int_overflow_test.mjs index 82554707887..667e82d1ef9 100644 --- a/tests/tests/src/int_overflow_test.mjs +++ b/tests/tests/src/int_overflow_test.mjs @@ -3,7 +3,7 @@ import * as Mocha from "mocha"; import * as Test_utils from "./test_utils.mjs"; import * as Stdlib_Float from "@rescript/runtime/lib/es6/Stdlib_Float.mjs"; -import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.mjs"; +import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.mjs"; function hash_variant(s) { let accu = 0; @@ -49,8 +49,14 @@ Mocha.describe("Int_overflow_test", () => { Mocha.test("hash_variant_test2", () => Test_utils.eq("File \"int_overflow_test.res\", line 61, characters 38-45", hash_variant2("xxyyzxzzyy"), -449896130)); Mocha.test("int_literal_flow", () => Test_utils.eq("File \"int_overflow_test.res\", line 62, characters 36-43", -1, -1)); Mocha.test("int_literal_flow2", () => Test_utils.eq("File \"int_overflow_test.res\", line 63, characters 37-44", -1, -1)); - Mocha.test("float_conversion_test1", () => Test_utils.eq("File \"int_overflow_test.res\", line 65, characters 7-14", Stdlib_Option.map(Stdlib_Float.fromString("3"), prim => prim | 0), 3)); - Mocha.test("float_conversion_test2", () => Test_utils.eq("File \"int_overflow_test.res\", line 68, characters 7-14", Stdlib_Option.map(Stdlib_Float.fromString("3.2"), prim => prim | 0), 3)); + Mocha.test("float_conversion_test1", () => { + let opt = Stdlib_Float.fromString("3"); + Test_utils.eq("File \"int_overflow_test.res\", line 65, characters 7-14", opt !== undefined ? Primitive_option.some(Primitive_option.valFromOption(opt) | 0) : undefined, 3); + }); + Mocha.test("float_conversion_test2", () => { + let opt = Stdlib_Float.fromString("3.2"); + Test_utils.eq("File \"int_overflow_test.res\", line 68, characters 7-14", opt !== undefined ? Primitive_option.some(Primitive_option.valFromOption(opt) | 0) : undefined, 3); + }); }); let max_int = 2147483647; diff --git a/tests/tests/src/stdlib/Stdlib_ObjectTests.mjs b/tests/tests/src/stdlib/Stdlib_ObjectTests.mjs index 42845195c22..dc668bf4c42 100644 --- a/tests/tests/src/stdlib/Stdlib_ObjectTests.mjs +++ b/tests/tests/src/stdlib/Stdlib_ObjectTests.mjs @@ -4,6 +4,7 @@ import * as Test from "./Test.mjs"; import * as Stdlib_BigInt from "@rescript/runtime/lib/es6/Stdlib_BigInt.mjs"; import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.mjs"; import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.mjs"; +import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.mjs"; let eq = Primitive_object.equal; @@ -476,10 +477,14 @@ runGetTest({ 3 ] }), - get: i => Stdlib_Option.getOr(Stdlib_Option.map(i["a"], i => i.concat([ - 4, - 5 - ])), []), + get: i => { + let opt = i["a"]; + let f = i => i.concat([ + 4, + 5 + ]); + return Stdlib_Option.getOr(opt !== undefined ? Primitive_option.some(f(Primitive_option.valFromOption(opt))) : undefined, []); + }, expected: [ 1, 2, diff --git a/tests/tests/src/stdlib/Stdlib_TempTests.mjs b/tests/tests/src/stdlib/Stdlib_TempTests.mjs index b4eea337efa..1836e26f7d6 100644 --- a/tests/tests/src/stdlib/Stdlib_TempTests.mjs +++ b/tests/tests/src/stdlib/Stdlib_TempTests.mjs @@ -202,13 +202,11 @@ console.log(regex.test(string)); let result = regex.exec(string); -let result$1 = (result == null) ? undefined : Primitive_option.some(result); - -console.log(Stdlib_Option.map(result$1, prim => prim.input)); +console.log(!(result == null) ? Primitive_option.some(result.input) : undefined); -console.log(Stdlib_Option.map(result$1, prim => prim.index)); +console.log(!(result == null) ? Primitive_option.some(result.index) : undefined); -console.log(Stdlib_Option.map(result$1, prim => prim.slice(1))); +console.log(!(result == null) ? Primitive_option.some(result.slice(1)) : undefined); console.info(""); @@ -321,6 +319,8 @@ let formatter = Stdlib_IntlTests.formatter; let segments = Stdlib_IntlTests.segments; +let result$1 = (result == null) ? undefined : Primitive_option.some(result); + export { _collator, collator, From 3da43adcaacbf839f355bb7d3068407d1b8f9acb Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 28 Sep 2026 09:46:02 +0200 Subject: [PATCH 2/7] Inline additional Option helpers across modules Signed-off-by: Christoph Knittel --- packages/@rescript/runtime/Stdlib_Option.res | 6 + .../src/cross_module_inline_opt_in_test.mjs | 105 ++++++++++++++++-- .../src/cross_module_inline_opt_in_test.res | 40 +++++++ 3 files changed, 141 insertions(+), 10 deletions(-) diff --git a/packages/@rescript/runtime/Stdlib_Option.res b/packages/@rescript/runtime/Stdlib_Option.res index 6c7bb7565a4..3a4ccc9800c 100644 --- a/packages/@rescript/runtime/Stdlib_Option.res +++ b/packages/@rescript/runtime/Stdlib_Option.res @@ -5,6 +5,7 @@ */ type t<'a> = option<'a> = None | Some('a) +@inline(crossModule) let filter = (opt, p) => switch opt { | Some(x) as option if p(x) => option @@ -32,6 +33,7 @@ let getOrThrow = (x, ~message=?) => external getUnsafe: option<'a> => 'a = "%identity" +@inline(crossModule) let mapOr = (opt, default, f) => switch opt { | Some(x) => f(x) @@ -52,24 +54,28 @@ let flatMap = (opt, f) => | None => None } +@inline(crossModule) let getOr = (opt, default) => switch opt { | Some(x) => x | None => default } +@inline(crossModule) let orElse = (opt, other) => switch opt { | Some(_) as some => some | None => other } +@inline(crossModule) let isSome = x => switch x { | Some(_) => true | None => false } +@inline(crossModule) let isNone = x => x == None let equal = (a, b, eq) => diff --git a/tests/tests/src/cross_module_inline_opt_in_test.mjs b/tests/tests/src/cross_module_inline_opt_in_test.mjs index 9547f3661d3..d25c49dcaf1 100644 --- a/tests/tests/src/cross_module_inline_opt_in_test.mjs +++ b/tests/tests/src/cross_module_inline_opt_in_test.mjs @@ -32,6 +32,44 @@ function forEachValue(opt, f) { } } +function filterValue(opt, p) { + if (opt !== undefined && p(Primitive_option.valFromOption(opt))) { + return opt; + } +} + +function mapOrValue(opt, $$default, f) { + if (opt !== undefined) { + return f(Primitive_option.valFromOption(opt)); + } else { + return $$default; + } +} + +function getOrValue(opt, $$default) { + if (opt !== undefined) { + return Primitive_option.valFromOption(opt); + } else { + return $$default; + } +} + +function orElseValue(opt, other) { + if (opt !== undefined) { + return opt; + } else { + return other; + } +} + +function isSomeValue(opt) { + return opt !== undefined; +} + +function isNoneValue(opt) { + return opt === undefined; +} + let visited = []; visited.push(4); @@ -40,22 +78,63 @@ let fromOrdinary = Cross_module_inline_export.ordinary(3, x => x + 1 | 0); Mocha.describe("cross-module inline opt-in", () => { Mocha.test("Option helpers", () => { - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 21, characters 7-14", mapped, 3); - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 22, characters 7-14", flatMapped, 4); - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 23, characters 7-14", mappedNested, Primitive_option.some(undefined)); - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 24, characters 7-14", mapValue(2), 3); - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 25, characters 7-14", mapValue(undefined), undefined); - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 26, characters 7-14", flatMapValue(2), 4); - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 27, characters 7-14", flatMapValue(undefined), undefined); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 27, characters 7-14", mapped, 3); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 28, characters 7-14", flatMapped, 4); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 29, characters 7-14", mappedNested, Primitive_option.some(undefined)); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 30, characters 7-14", mapValue(2), 3); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 31, characters 7-14", mapValue(undefined), undefined); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 32, characters 7-14", flatMapValue(2), 4); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 33, characters 7-14", flatMapValue(undefined), undefined); visited.push(5); - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 29, characters 7-14", visited, [ + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 35, characters 7-14", visited, [ 4, 5 ]); }); Mocha.test("only marked exports are inlined", () => { - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 32, characters 7-14", 4, 4); - Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 33, characters 7-14", fromOrdinary, 4); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 38, characters 7-14", 4, 4); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 39, characters 7-14", fromOrdinary, 4); + }); + Mocha.test("additional Option helpers", () => { + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 42, characters 7-14", 4, 4); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 43, characters 7-14", undefined, undefined); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 44, characters 7-14", undefined, undefined); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 45, characters 7-14", 3, 3); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 46, characters 7-14", 0, 0); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 47, characters 7-14", 2, 2); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 48, characters 7-14", 0, 0); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 49, characters 7-14", 2, 2); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 50, characters 7-14", 3, 3); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 51, characters 7-14", true, true); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 52, characters 7-14", false, false); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 53, characters 7-14", false, false); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 54, characters 7-14", true, true); + }); + Mocha.test("eager fallback arguments", () => { + let effects = []; + let getDefault = () => { + effects.push("getOr"); + return 0; + }; + let mapDefault = () => { + effects.push("mapOr"); + return 0; + }; + let other = () => { + effects.push("orElse"); + return 0; + }; + getDefault(); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 70, characters 7-14", 2, 2); + mapDefault(); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 71, characters 7-14", 2, 2); + other(); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 72, characters 7-14", 2, 2); + Test_utils.eq("File \"cross_module_inline_opt_in_test.res\", line 73, characters 7-14", effects, [ + "getOr", + "mapOr", + "orElse" + ]); }); }); @@ -69,6 +148,12 @@ export { mapValue, flatMapValue, forEachValue, + filterValue, + mapOrValue, + getOrValue, + orElseValue, + isSomeValue, + isNoneValue, visited, fromExport, fromOrdinary, diff --git a/tests/tests/src/cross_module_inline_opt_in_test.res b/tests/tests/src/cross_module_inline_opt_in_test.res index 6cc81e946dc..b38c8d8ddf1 100644 --- a/tests/tests/src/cross_module_inline_opt_in_test.res +++ b/tests/tests/src/cross_module_inline_opt_in_test.res @@ -8,6 +8,12 @@ let mappedNested = nested->Option.map(x => x) let mapValue = opt => opt->Option.map(x => x + 1) let flatMapValue = opt => opt->Option.flatMap(x => Some(x + 2)) let forEachValue = (opt, f) => opt->Option.forEach(f) +let filterValue = (opt, p) => opt->Option.filter(p) +let mapOrValue = (opt, default, f) => opt->Option.mapOr(default, f) +let getOrValue = (opt, default) => opt->Option.getOr(default) +let orElseValue = (opt, other) => opt->Option.orElse(other) +let isSomeValue = opt => opt->Option.isSome +let isNoneValue = opt => opt->Option.isNone let visited: array = [] let _ = Some(4)->Option.forEach(x => visited->Array.push(x)) @@ -32,4 +38,38 @@ describe("cross-module inline opt-in", () => { eq(__LOC__, fromExport, 4) eq(__LOC__, fromOrdinary, 4) }) + test("additional Option helpers", () => { + eq(__LOC__, filterValue(Some(4), x => x > 3), Some(4)) + eq(__LOC__, filterValue(Some(2), x => x > 3), None) + eq(__LOC__, filterValue(None, _ => true), None) + eq(__LOC__, mapOrValue(Some(2), 0, x => x + 1), 3) + eq(__LOC__, mapOrValue(None, 0, x => x + 1), 0) + eq(__LOC__, getOrValue(Some(2), 0), 2) + eq(__LOC__, getOrValue(None, 0), 0) + eq(__LOC__, orElseValue(Some(2), Some(3)), Some(2)) + eq(__LOC__, orElseValue(None, Some(3)), Some(3)) + eq(__LOC__, isSomeValue(Some(2)), true) + eq(__LOC__, isSomeValue(None), false) + eq(__LOC__, isNoneValue(Some(2)), false) + eq(__LOC__, isNoneValue(None), true) + }) + test("eager fallback arguments", () => { + let effects = [] + let getDefault = () => { + effects->Array.push("getOr") + 0 + } + let mapDefault = () => { + effects->Array.push("mapOr") + 0 + } + let other = () => { + effects->Array.push("orElse") + Some(0) + } + eq(__LOC__, Some(2)->Option.getOr(getDefault()), 2) + eq(__LOC__, Some(2)->Option.mapOr(mapDefault(), x => x), 2) + eq(__LOC__, Some(2)->Option.orElse(other()), Some(2)) + eq(__LOC__, effects, ["getOr", "mapOr", "orElse"]) + }) }) From cb4faeddbec58a351150e54b413cf6c34576fbfa Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 28 Sep 2026 09:49:51 +0200 Subject: [PATCH 3/7] Document cross-module inlining feature Signed-off-by: Christoph Knittel --- changes/cross-module-inline-option.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/cross-module-inline-option.feature.md diff --git a/changes/cross-module-inline-option.feature.md b/changes/cross-module-inline-option.feature.md new file mode 100644 index 00000000000..5eb9e571167 --- /dev/null +++ b/changes/cross-module-inline-option.feature.md @@ -0,0 +1 @@ +- Allow selected functions to opt into cross-module inlining with `@inline(crossModule)` and inline common `Option` helpers in application code. From 0e010a0c6498de170bb448b56acd498eb6ebb087 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 28 Sep 2026 09:55:49 +0200 Subject: [PATCH 4/7] Refresh Option inlining snapshots after clean build Signed-off-by: Christoph Knittel --- tests/belt_tests/src/bs_stack_test.mjs | 9 ++++----- tests/tests/src/flat_make_export_test.mjs | 4 ++-- tests/tests/src/stdlib/Stdlib_ObjectTests.mjs | 11 ++++++++--- tests/tests/src/stdlib/Stdlib_TempTests.mjs | 5 +++-- tests/tests/src/stdlib/Stdlib_TestTests.mjs | 6 ++++-- tests/tests/src/stdlib/intl/Stdlib_IntlTests.mjs | 4 ++-- 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/belt_tests/src/bs_stack_test.mjs b/tests/belt_tests/src/bs_stack_test.mjs index 0729f2a230d..e6c8f4b34d1 100644 --- a/tests/belt_tests/src/bs_stack_test.mjs +++ b/tests/belt_tests/src/bs_stack_test.mjs @@ -2,7 +2,6 @@ import * as Mocha from "mocha"; import * as Test_utils from "./test_utils.mjs"; -import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.mjs"; import * as Belt_MutableQueue from "@rescript/belt/src/Belt_MutableQueue.mjs"; import * as Belt_MutableStack from "@rescript/belt/src/Belt_MutableStack.mjs"; @@ -10,7 +9,7 @@ function inOrder(v) { let current = v; let s = Belt_MutableStack.make(); let q = Belt_MutableQueue.make(); - while (Stdlib_Option.isSome(current)) { + while (current !== undefined) { let v$1 = current; Belt_MutableStack.push(s, v$1); current = v$1.left; @@ -20,7 +19,7 @@ function inOrder(v) { let v$2 = current; Belt_MutableQueue.add(q, v$2.value); current = v$2.right; - while (Stdlib_Option.isSome(current)) { + while (current !== undefined) { let v$3 = current; Belt_MutableStack.push(s, v$3); current = v$3.left; @@ -33,7 +32,7 @@ function inOrder3(v) { let current = v; let s = Belt_MutableStack.make(); let q = Belt_MutableQueue.make(); - while (Stdlib_Option.isSome(current)) { + while (current !== undefined) { let v$1 = current; Belt_MutableStack.push(s, v$1); current = v$1.left; @@ -41,7 +40,7 @@ function inOrder3(v) { Belt_MutableStack.dynamicPopIter(s, popped => { Belt_MutableQueue.add(q, popped.value); let current = popped.right; - while (Stdlib_Option.isSome(current)) { + while (current !== undefined) { let v = current; Belt_MutableStack.push(s, v); current = v.left; diff --git a/tests/tests/src/flat_make_export_test.mjs b/tests/tests/src/flat_make_export_test.mjs index 8b3f96f0d7c..d780da41cc8 100644 --- a/tests/tests/src/flat_make_export_test.mjs +++ b/tests/tests/src/flat_make_export_test.mjs @@ -2,7 +2,7 @@ import * as Mocha from "mocha"; import * as Test_utils from "./test_utils.mjs"; -import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.mjs"; +import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.mjs"; import * as Flat_make_export_use from "./flat_make_export_use.mjs"; Mocha.describe("Flat_make_export_test", () => { @@ -22,7 +22,7 @@ Mocha.describe("Flat_make_export_test", () => { Test_utils.eq("File \"flat_make_export_test.res\", line 23, characters 7-14", Flat_make_export_use.nested, "nested"); Test_utils.eq("File \"flat_make_export_test.res\", line 24, characters 7-14", Flat_make_export_use.recursive, "recursive"); Test_utils.eq("File \"flat_make_export_test.res\", line 25, characters 7-14", Flat_make_export_use.coerced, "coerced"); - Test_utils.eq("File \"flat_make_export_test.res\", line 26, characters 7-14", Stdlib_Option.map(Flat_make_export_use.rebound, f => f()), "rebound"); + Test_utils.eq("File \"flat_make_export_test.res\", line 26, characters 7-14", Flat_make_export_use.rebound !== undefined ? Primitive_option.some(Primitive_option.valFromOption(Flat_make_export_use.rebound)()) : undefined, "rebound"); Test_utils.eq("File \"flat_make_export_test.res\", line 27, characters 7-14", Flat_make_export_use.applied, "applied"); Test_utils.eq("File \"flat_make_export_test.res\", line 28, characters 7-14", Flat_make_export_use.included, "one"); Test_utils.eq("File \"flat_make_export_test.res\", line 29, characters 7-14", Flat_make_export_use.aliased, "one"); diff --git a/tests/tests/src/stdlib/Stdlib_ObjectTests.mjs b/tests/tests/src/stdlib/Stdlib_ObjectTests.mjs index dc668bf4c42..d7f6ece3742 100644 --- a/tests/tests/src/stdlib/Stdlib_ObjectTests.mjs +++ b/tests/tests/src/stdlib/Stdlib_ObjectTests.mjs @@ -2,7 +2,6 @@ import * as Test from "./Test.mjs"; import * as Stdlib_BigInt from "@rescript/runtime/lib/es6/Stdlib_BigInt.mjs"; -import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.mjs"; import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.mjs"; import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.mjs"; @@ -446,7 +445,7 @@ runGetTest({ source: () => ({ a: 1 }), - get: i => Stdlib_Option.isSome(i["toString"]), + get: i => i["toString"] !== undefined, expected: true }); @@ -483,7 +482,13 @@ runGetTest({ 4, 5 ]); - return Stdlib_Option.getOr(opt !== undefined ? Primitive_option.some(f(Primitive_option.valFromOption(opt))) : undefined, []); + let opt$1 = opt !== undefined ? Primitive_option.some(f(Primitive_option.valFromOption(opt))) : undefined; + let $$default = []; + if (opt$1 !== undefined) { + return Primitive_option.valFromOption(opt$1); + } else { + return $$default; + } }, expected: [ 1, diff --git a/tests/tests/src/stdlib/Stdlib_TempTests.mjs b/tests/tests/src/stdlib/Stdlib_TempTests.mjs index 1836e26f7d6..82ed8aeac8b 100644 --- a/tests/tests/src/stdlib/Stdlib_TempTests.mjs +++ b/tests/tests/src/stdlib/Stdlib_TempTests.mjs @@ -5,7 +5,6 @@ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.mjs"; import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.mjs"; import * as Stdlib_Float from "@rescript/runtime/lib/es6/Stdlib_Float.mjs"; import * as Stdlib_BigInt from "@rescript/runtime/lib/es6/Stdlib_BigInt.mjs"; -import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.mjs"; import * as Primitive_bigint from "@rescript/runtime/lib/es6/Primitive_bigint.mjs"; import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.mjs"; import * as Stdlib_IntlTests from "./intl/Stdlib_IntlTests.mjs"; @@ -137,7 +136,9 @@ console.info("BigInt"); console.info("---"); -console.log(Primitive_bigint.div(BigInt(1), Stdlib_Option.getOr(Stdlib_BigInt.fromFloat(12.0), 0n))); +let opt = Stdlib_BigInt.fromFloat(12.0); + +console.log(Primitive_bigint.div(BigInt(1), opt !== undefined ? Primitive_option.valFromOption(opt) : 0n)); console.info(""); diff --git a/tests/tests/src/stdlib/Stdlib_TestTests.mjs b/tests/tests/src/stdlib/Stdlib_TestTests.mjs index 3f5ed23ec51..9ce0272b8ba 100644 --- a/tests/tests/src/stdlib/Stdlib_TestTests.mjs +++ b/tests/tests/src/stdlib/Stdlib_TestTests.mjs @@ -2,8 +2,8 @@ import * as Test from "./Test.mjs"; import * as Stdlib_BigInt from "@rescript/runtime/lib/es6/Stdlib_BigInt.mjs"; -import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.mjs"; import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.mjs"; +import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.mjs"; let eq = Primitive_object.equal; @@ -11,7 +11,9 @@ let nan = NaN; let infinity = Number.POSITIVE_INFINITY; -let bign = Stdlib_Option.getOr(Stdlib_BigInt.fromFloat(Number.MAX_VALUE), 0n); +let opt = Stdlib_BigInt.fromFloat(Number.MAX_VALUE); + +let bign = opt !== undefined ? Primitive_option.valFromOption(opt) : 0n; let bign$1 = bign + bign; diff --git a/tests/tests/src/stdlib/intl/Stdlib_IntlTests.mjs b/tests/tests/src/stdlib/intl/Stdlib_IntlTests.mjs index 85e250c1b5e..7945fa8b097 100644 --- a/tests/tests/src/stdlib/intl/Stdlib_IntlTests.mjs +++ b/tests/tests/src/stdlib/intl/Stdlib_IntlTests.mjs @@ -1,7 +1,6 @@ // Generated by ReScript, PLEASE EDIT WITH CARE import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.mjs"; -import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.mjs"; import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.mjs"; import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.mjs"; import * as Stdlib_Intl_LocaleTest from "./Stdlib_Intl_LocaleTest.mjs"; @@ -58,7 +57,8 @@ try { let e$2 = Primitive_exceptions.internalToException(raw_e$2); if (e$2.RE_EXN_ID === "JsExn") { let e$3 = e$2._1; - let message = Stdlib_Option.map(Stdlib_JsExn.message(e$3), prim => prim.toLowerCase()); + let opt = Stdlib_JsExn.message(e$3); + let message = opt !== undefined ? Primitive_option.some(Primitive_option.valFromOption(opt).toLowerCase()) : undefined; if (message === "invalid key : someinvalidkey") { console.log("Caught expected error"); } else { From a7681f89ada3cf4d8c6363b41580b9d43a73836d Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 28 Sep 2026 11:19:45 +0200 Subject: [PATCH 5/7] Track CMJ changes for cross-module inlining Signed-off-by: Christoph Knittel --- compiler/core/js_cmj_format.ml | 30 +----------- compiler/core/js_cmj_format.mli | 5 +- rewatch/src/build/clean.rs | 2 +- rewatch/src/build/compile.rs | 16 +++--- scripts/test.js | 8 ++- .../build_tests/cross_module_inline/input.js | 15 ++---- .../.gitignore | 2 + .../input.js | 49 +++++++++++++++++++ .../node_modules/inline-exporter/package.json | 1 + .../inline-exporter/rescript.json | 5 ++ .../inline-exporter/src/Exporter.res | 2 + .../rescript.json | 6 +++ .../src/B.res | 1 + 13 files changed, 89 insertions(+), 53 deletions(-) create mode 100644 tests/build_tests/cross_module_inline_separate_builds/.gitignore create mode 100644 tests/build_tests/cross_module_inline_separate_builds/input.js create mode 100644 tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/package.json create mode 100644 tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/rescript.json create mode 100644 tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/src/Exporter.res create mode 100644 tests/build_tests/cross_module_inline_separate_builds/rescript.json create mode 100644 tests/build_tests/cross_module_inline_separate_builds/src/B.res diff --git a/compiler/core/js_cmj_format.ml b/compiler/core/js_cmj_format.ml index ee0962b442d..353fe04670a 100644 --- a/compiler/core/js_cmj_format.ml +++ b/compiler/core/js_cmj_format.ml @@ -97,40 +97,14 @@ let to_file name ~check_exists (v : t) = let s = Marshal.to_string v [] in let cur_digest = Digest.string s in let header = cur_digest in - if not (check_exists && for_sure_not_changed name header) then ( + if not (check_exists && for_sure_not_changed name header) then let oc = open_out_bin (Compiler_request_state.resolve_path name) in Fun.protect ~finally:(fun () -> close_out_noerr oc) (fun () -> output_string oc header; output_string oc s; - close_out oc)); - (* Rewatch normally rebuilds dependents only when their dependency's .cmi - changes. An exported inline body can change without changing its type, so - give Rewatch a small digest to track for modules that export one. *) - let inline_digest_file = name ^ ".inline" in - let has_inline_export = - Array.exists - (fun (value : keyed_cmj_value) -> - match value.persistent_closed_lambda with - | Some (Lfunction f) -> f.attr.inline = Cross_module_inline - | _ -> false) - v.values - in - if has_inline_export then ( - if not (check_exists && for_sure_not_changed inline_digest_file cur_digest) - then ( - let oc = - open_out_bin (Compiler_request_state.resolve_path inline_digest_file) - in - Fun.protect - ~finally:(fun () -> close_out_noerr oc) - (fun () -> - output_string oc cur_digest; - close_out oc))) - else - let path = Compiler_request_state.resolve_path inline_digest_file in - if Sys.file_exists path then Sys.remove path + close_out oc) let key_comp a b = Ext_string.compare a b.name diff --git a/compiler/core/js_cmj_format.mli b/compiler/core/js_cmj_format.mli index 439afe49839..c5c5df2496e 100644 --- a/compiler/core/js_cmj_format.mli +++ b/compiler/core/js_cmj_format.mli @@ -81,9 +81,8 @@ val single_na : arity val from_file : string -> t -(* Write the .cmj and, when it exports a cross-module inline function, a - .cmj.inline digest used by Rewatch to invalidate callers. Unchanged files - are not rewritten when [check_exists] is true. *) +(* Write the .cmj. Unchanged files are not rewritten when [check_exists] is + true, so Rewatch can use changes to this file to invalidate callers. *) val to_file : string -> check_exists:bool -> t -> unit type path = string diff --git a/rewatch/src/build/clean.rs b/rewatch/src/build/clean.rs index 4223477f9bb..8ef27094707 100644 --- a/rewatch/src/build/clean.rs +++ b/rewatch/src/build/clean.rs @@ -62,7 +62,7 @@ fn remove_compile_asset(package: &packages::Package, source_file: &Path, extensi fn remove_compile_assets(package: &packages::Package, source_file: &Path) { // optimization // only issue cmti if there is an interfacce file - for extension in &["cmj", "cmj.inline", "cmi", "cmt", "cmti"] { + for extension in &["cmj", "cmi", "cmt", "cmti"] { remove_compile_asset(package, source_file, extension); } } diff --git a/rewatch/src/build/compile.rs b/rewatch/src/build/compile.rs index 353b7ed8cf4..24793435052 100644 --- a/rewatch/src/build/compile.rs +++ b/rewatch/src/build/compile.rs @@ -255,13 +255,13 @@ fn compile_one( "cmi", ); let cmi_digest = helpers::compute_file_hash(Path::new(&cmi_path)); - let inline_digest_path = helpers::get_bs_compiler_asset( + let cmj_path = helpers::get_bs_compiler_asset( package, &package.namespace, &source_file.implementation.path, - ".cmj.inline", + ".cmj", ); - let inline_digest = helpers::compute_file_hash(Path::new(&inline_digest_path)); + let cmj_digest = helpers::compute_file_hash(Path::new(&cmj_path)); let interface_result = source_file.interface.as_ref().map(|iface| { compile_file( @@ -282,7 +282,7 @@ fn compile_one( warn_error_override, ); let cmi_digest_after = helpers::compute_file_hash(Path::new(&cmi_path)); - let inline_digest_after = helpers::compute_file_hash(Path::new(&inline_digest_path)); + let cmj_digest_after = helpers::compute_file_hash(Path::new(&cmj_path)); // If the cmi is byte-for-byte unchanged, downstream modules can // short-circuit — we check both interface and implementation @@ -292,15 +292,15 @@ fn compile_one( (cmi_digest, cmi_digest_after), (Some(a), Some(b)) if a == b ); - // A marked inline body can change without changing the CMI. The - // compiler emits a digest only for modules that export such a body. - let is_clean_inline = inline_digest == inline_digest_after; + // Exported JavaScript metadata, including inline bodies, can + // change without changing the CMI. + let is_clean_cmj = cmj_digest == cmj_digest_after; CompletionMsg { module_name: module_name.to_string(), result, interface_result, - is_clean: is_clean_cmi && is_clean_inline, + is_clean: is_clean_cmi && is_clean_cmj, is_compiled: true, } } diff --git a/scripts/test.js b/scripts/test.js index bd76a7479a3..1931e86be75 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -73,8 +73,12 @@ if (mochaTest) { const beltPackageDir = path.join(projectDir, "packages/@rescript/belt"); const beltTestDir = path.join(projectDir, "tests/belt_tests"); - // No need to clean beforehand, rewatch detects changes to the compiler binary - // and rebuilds automatically in that case. + // The runtime is built before this test project. Its inline exports can change + // without changing the compiler binary, so regenerate checked-in JS snapshots. + await execClean([], { + cwd: compilerTestDir, + stdio: "inherit", + }); await execBuild([], { cwd: compilerTestDir, stdio: "inherit", diff --git a/tests/build_tests/cross_module_inline/input.js b/tests/build_tests/cross_module_inline/input.js index 144c52842c5..1b2d86d74a3 100644 --- a/tests/build_tests/cross_module_inline/input.js +++ b/tests/build_tests/cross_module_inline/input.js @@ -8,22 +8,16 @@ import { setup } from "#dev/process"; const { execBuildOrThrow, execClean } = setup(import.meta.dirname); const source = path.join(import.meta.dirname, "src", "A.res"); const output = path.join(import.meta.dirname, "src", "B.js"); -const inlineDigest = path.join( - import.meta.dirname, - "lib", - "bs", - "src", - "A.cmj.inline", -); +const cmj = path.join(import.meta.dirname, "lib", "bs", "src", "A.cmj"); const original = await fs.readFile(source, "utf8"); await execClean(); try { await execBuildOrThrow(); assert.match(await fs.readFile(output, "utf8"), /let result = 2;/); - await fs.access(inlineDigest); + await fs.access(cmj); await execClean(); - await assert.rejects(fs.access(inlineDigest)); + await assert.rejects(fs.access(cmj)); await execBuildOrThrow(); // The public type stays the same, but the saved inline body changes. @@ -31,11 +25,10 @@ try { await execBuildOrThrow(); assert.match(await fs.readFile(output, "utf8"), /let result = 3;/); - // Removing the marker removes the digest and restores a normal call. + // Removing the marker restores a normal call. await fs.writeFile(source, original.replace("@inline(crossModule)\n", "")); await execBuildOrThrow(); assert.match(await fs.readFile(output, "utf8"), /A\.bump\(1\)/); - await assert.rejects(fs.access(inlineDigest)); } finally { await fs.writeFile(source, original); await execClean(); diff --git a/tests/build_tests/cross_module_inline_separate_builds/.gitignore b/tests/build_tests/cross_module_inline_separate_builds/.gitignore new file mode 100644 index 00000000000..d64bd833ba7 --- /dev/null +++ b/tests/build_tests/cross_module_inline_separate_builds/.gitignore @@ -0,0 +1,2 @@ +!node_modules +node_modules/**/lib diff --git a/tests/build_tests/cross_module_inline_separate_builds/input.js b/tests/build_tests/cross_module_inline_separate_builds/input.js new file mode 100644 index 00000000000..6f9690c2f7b --- /dev/null +++ b/tests/build_tests/cross_module_inline_separate_builds/input.js @@ -0,0 +1,49 @@ +// @ts-check + +import * as assert from "node:assert"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { projectDir } from "#dev/paths"; +import { setup } from "#dev/process"; + +const root = import.meta.dirname; +const exporter = path.join(root, "node_modules", "inline-exporter"); +const source = path.join(exporter, "src", "Exporter.res"); +const output = path.join(root, "src", "B.js"); +const inlineArtifact = path.join( + exporter, + "lib", + "bs", + "src", + "Exporter.cmj.inline", +); +const original = await fs.readFile(source, "utf8"); +const rootBuild = setup(root); +const exporterBuild = setup(exporter); +const exporterOptions = { + env: { + ...process.env, + RESCRIPT_RUNTIME: path.join(projectDir, "packages/@rescript/runtime"), + }, +}; + +await rootBuild.execClean(); +try { + await exporterBuild.execBuildOrThrow([], exporterOptions); + await rootBuild.execBuildOrThrow(); + assert.match(await fs.readFile(output, "utf8"), /let result = 2;/); + await assert.rejects(fs.access(inlineArtifact)); + + await fs.writeFile(source, original.replace("x + 1", "x + 2")); + await exporterBuild.execBuildOrThrow([], exporterOptions); + await rootBuild.execBuildOrThrow(); + assert.match(await fs.readFile(output, "utf8"), /let result = 3;/); + + await fs.writeFile(source, original.replace("@inline(crossModule)\n", "")); + await exporterBuild.execBuildOrThrow([], exporterOptions); + await rootBuild.execBuildOrThrow(); + assert.match(await fs.readFile(output, "utf8"), /Exporter\.bump\(1\)/); +} finally { + await fs.writeFile(source, original); + await rootBuild.execClean(); +} diff --git a/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/package.json b/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/package.json new file mode 100644 index 00000000000..b8ad3897565 --- /dev/null +++ b/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/package.json @@ -0,0 +1 @@ +{"name": "inline-exporter", "version": "0.0.0"} diff --git a/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/rescript.json b/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/rescript.json new file mode 100644 index 00000000000..e85feb794a9 --- /dev/null +++ b/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/rescript.json @@ -0,0 +1,5 @@ +{ + "name": "inline-exporter", + "sources": [{"dir": "src"}], + "package-specs": {"module": "commonjs", "in-source": true} +} diff --git a/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/src/Exporter.res b/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/src/Exporter.res new file mode 100644 index 00000000000..1224d11fc7d --- /dev/null +++ b/tests/build_tests/cross_module_inline_separate_builds/node_modules/inline-exporter/src/Exporter.res @@ -0,0 +1,2 @@ +@inline(crossModule) +let bump = x => x + 1 diff --git a/tests/build_tests/cross_module_inline_separate_builds/rescript.json b/tests/build_tests/cross_module_inline_separate_builds/rescript.json new file mode 100644 index 00000000000..441e424116a --- /dev/null +++ b/tests/build_tests/cross_module_inline_separate_builds/rescript.json @@ -0,0 +1,6 @@ +{ + "name": "cross-module-inline-separate-builds", + "sources": [{ "dir": "src" }], + "package-specs": { "module": "commonjs", "in-source": true }, + "dependencies": ["inline-exporter"] +} diff --git a/tests/build_tests/cross_module_inline_separate_builds/src/B.res b/tests/build_tests/cross_module_inline_separate_builds/src/B.res new file mode 100644 index 00000000000..516009f1f90 --- /dev/null +++ b/tests/build_tests/cross_module_inline_separate_builds/src/B.res @@ -0,0 +1 @@ +let result = Exporter.bump(1) From 7ce5284d7600e604cffcf1f5a15cd0fb9127a325 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Mon, 28 Sep 2026 11:53:04 +0200 Subject: [PATCH 6/7] Read CMJ digest header for incremental rebuilds Signed-off-by: Christoph Knittel --- rewatch/src/build/compile.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/rewatch/src/build/compile.rs b/rewatch/src/build/compile.rs index 24793435052..b6c1cd37cf9 100644 --- a/rewatch/src/build/compile.rs +++ b/rewatch/src/build/compile.rs @@ -17,6 +17,7 @@ use log::{debug, info, trace, warn}; use rayon::prelude::*; use std::cmp::Ordering; use std::collections::BinaryHeap; +use std::io::Read; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -34,6 +35,14 @@ fn compiler_output_to_string(bytes: &[u8]) -> String { String::from_utf8_lossy(bytes).to_string() } +// The compiler prefixes each .cmj with the 16-byte digest of its serialized +// contents. Reading that header avoids hashing the whole file again. +fn read_cmj_digest(path: &Path) -> Option<[u8; 16]> { + let mut digest = [0; 16]; + std::fs::File::open(path).ok()?.read_exact(&mut digest).ok()?; + Some(digest) +} + /// Execute js-post-build command for a compiled JavaScript file. /// The command runs in the directory containing the rescript.json that defines it. /// The absolute path to the JS file is passed as an argument. @@ -261,7 +270,7 @@ fn compile_one( &source_file.implementation.path, ".cmj", ); - let cmj_digest = helpers::compute_file_hash(Path::new(&cmj_path)); + let cmj_digest = read_cmj_digest(Path::new(&cmj_path)); let interface_result = source_file.interface.as_ref().map(|iface| { compile_file( @@ -282,7 +291,7 @@ fn compile_one( warn_error_override, ); let cmi_digest_after = helpers::compute_file_hash(Path::new(&cmi_path)); - let cmj_digest_after = helpers::compute_file_hash(Path::new(&cmj_path)); + let cmj_digest_after = read_cmj_digest(Path::new(&cmj_path)); // If the cmi is byte-for-byte unchanged, downstream modules can // short-circuit — we check both interface and implementation @@ -1412,6 +1421,19 @@ mod tests { assert!(decoded.starts_with("Warning ")); } + #[test] + fn read_cmj_digest_reads_only_the_header() { + let temp_dir = TempDir::new().expect("temp dir should be created"); + let cmj_path = temp_dir.path().join("Example.cmj"); + let digest = [42; 16]; + fs::write(&cmj_path, [digest.as_slice(), b"serialized contents"].concat()) + .expect("cmj should be written"); + + assert_eq!(read_cmj_digest(&cmj_path), Some(digest)); + fs::write(&cmj_path, b"short").expect("cmj should be rewritten"); + assert_eq!(read_cmj_digest(&cmj_path), None); + } + fn test_project_context(root: &Path) -> ProjectContext { let config = config::tests::create_config(config::tests::CreateConfigArgs { name: "test-root".to_string(), From 08056a9f86cb4e53764998af8babb2f983d8661f Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Tue, 27 Oct 2026 07:15:14 +0000 Subject: [PATCH 7/7] Recompile dependents of a changed CMJ in the OCaml build system Why: The OCaml build system, now the default `rescript`, recompiled dependents only when a module's CMI changed. An `@inline(crossModule)` body lives in the CMJ and can change while the CMI stays the same, so callers kept the old inlined code, during a build and on the next build after the dependency was compiled separately. Rust rewatch already compares CMJ digests since the previous commits. Publishing a byte-different CMJ now marks dependents dirty like a changed CMI, and the startup freshness check compares a dependent's CMT with the later of its dependency's CMI and CMJ. Review: Does every implementation change now recompile dependents? Only those whose CMJ bytes change, matching the Rust implementation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kbf3gKsWykY6KH4QSJ9Wrs Signed-off-by: Christoph Knittel --- rewatch-ocaml/build_state.ml | 3 +-- rewatch-ocaml/build_state.mli | 1 + rewatch-ocaml/compile_assets.ml | 15 ++++++++++-- rewatch-ocaml/compile_assets.mli | 8 +++++++ rewatch-ocaml/compiler_process.ml | 23 ++++++++++++++----- rewatch-ocaml/module_graph.ml | 6 ++--- .../compile_assets_tests.ml | 14 +++++++++++ 7 files changed, 56 insertions(+), 14 deletions(-) diff --git a/rewatch-ocaml/build_state.ml b/rewatch-ocaml/build_state.ml index 853f5b8e0ac..9ca90429db4 100644 --- a/rewatch-ocaml/build_state.ml +++ b/rewatch-ocaml/build_state.ml @@ -111,8 +111,7 @@ let record_published_cmi ?dirty_propagation state ~compile_assets module_ ~path 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) + Compile_assets.published compile_assets module_.key let record_successful_compile ~compile_assets module_ ~cmt_path = Compile_assets.refresh_cmt compile_assets ~key:module_.key ~path:cmt_path; diff --git a/rewatch-ocaml/build_state.mli b/rewatch-ocaml/build_state.mli index f9988a20cbf..0ecf301a98a 100644 --- a/rewatch-ocaml/build_state.mli +++ b/rewatch-ocaml/build_state.mli @@ -15,6 +15,7 @@ type module_ = { mutable dependents: String_set.t; mutable compile_dirty: bool; mutable last_compiled_cmi: float option; + (** See {!Compile_assets.published}. *) mutable last_compiled_cmt: float option; } diff --git a/rewatch-ocaml/compile_assets.ml b/rewatch-ocaml/compile_assets.ml index 20d8bc6c9ca..0fd82daadfb 100644 --- a/rewatch-ocaml/compile_assets.ml +++ b/rewatch-ocaml/compile_assets.ml @@ -8,6 +8,7 @@ type t = { ast_by_source: (string, entry) Hashtbl.t; cmi_by_module: (string, entry) Hashtbl.t; cmt_by_module: (string, entry) Hashtbl.t; + cmj_by_module: (string, entry) Hashtbl.t; } let ast_header path = @@ -21,7 +22,7 @@ let is_managed_basename basename = List.exists (Filename.check_suffix basename) cleanup_extensions let state_extension = function - | ".ast" | ".iast" | ".cmi" | ".cmt" -> true + | ".ast" | ".iast" | ".cmi" | ".cmj" | ".cmt" -> true | _ -> false let read_directory directory = @@ -58,6 +59,7 @@ 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 + | ".cmj" -> Hashtbl.replace state.cmj_by_module (module_key name) entry | _ -> () let create directories = @@ -69,6 +71,7 @@ let create directories = ast_by_source = Hashtbl.create 64; cmi_by_module = Hashtbl.create 64; cmt_by_module = Hashtbl.create 64; + cmj_by_module = Hashtbl.create 64; } in directories @@ -118,6 +121,12 @@ 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 published state key = + match (cmi state key, Hashtbl.find_opt state.cmj_by_module key) with + | None, _ -> None + | Some cmi, None -> Some cmi.modified + | Some cmi, Some cmj -> Some (Float.max cmi.modified cmj.modified) + let replace_from_path table key path = try Hashtbl.replace table key {path; modified = (Unix.stat path).Unix.st_mtime} @@ -125,7 +134,9 @@ let replace_from_path table key path = Hashtbl.remove table key let refresh_cmi state ~key ~path = - replace_from_path state.cmi_by_module key path + replace_from_path state.cmi_by_module key path; + replace_from_path state.cmj_by_module key + (Filename.remove_extension path ^ ".cmj") let refresh_cmt state ~key ~path = replace_from_path state.cmt_by_module key path diff --git a/rewatch-ocaml/compile_assets.mli b/rewatch-ocaml/compile_assets.mli index 53180ff4d62..a222923e0da 100644 --- a/rewatch-ocaml/compile_assets.mli +++ b/rewatch-ocaml/compile_assets.mli @@ -14,6 +14,14 @@ 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 published : t -> string -> float option +(** When the module's CMI, or its CMJ if newer, was last written; [None] without + a CMI. A CMJ can change without its CMI, for example when an exported inline + body changes, and dependents compiled before it are stale too. *) + val refresh_cmi : t -> key:string -> path:string -> unit +(** Also refreshes the CMJ next to [path]. *) + val refresh_cmt : t -> key:string -> path:string -> unit val refresh_ast : t -> source:string -> path:string -> unit diff --git a/rewatch-ocaml/compiler_process.ml b/rewatch-ocaml/compiler_process.ml index eec1a09c2ca..b41b6e53c43 100644 --- a/rewatch-ocaml/compiler_process.ml +++ b/rewatch-ocaml/compiler_process.ml @@ -124,7 +124,11 @@ let parse_request ~build_dir ~(config : Config.t) path = 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 +type compiler_artifact = Cmi | Cmj | Required of string | Optional of string + +(* The returned change tells whether dependents must recompile. Besides the + .cmi, this includes the .cmj: exported inline bodies can change without + changing the module's type. *) let publish_compiler_artifacts ~artifact_dir ~ocaml_dir ~basename artifacts = let cmi_change = ref Compiler_scheduler.Cmi_change_unknown in @@ -134,6 +138,7 @@ let publish_compiler_artifacts ~artifact_dir ~ocaml_dir ~basename artifacts = let extension = match artifact with | Cmi -> "cmi" + | Cmj -> "cmj" | Required name | Optional name -> name in let source = @@ -148,6 +153,14 @@ let publish_compiler_artifacts ~artifact_dir ~ocaml_dir ~basename artifacts = if File_util.copy_file_if_different source destination then Compiler_scheduler.Cmi_changed else Compiler_scheduler.Cmi_unchanged + | Cmj -> ( + match + (File_util.copy_file_if_different source destination, !cmi_change) + with + | true, _ -> cmi_change := Compiler_scheduler.Cmi_changed + | false, Compiler_scheduler.Cmi_change_unknown -> + cmi_change := Compiler_scheduler.Cmi_unchanged + | false, (Compiler_scheduler.Cmi_changed | Cmi_unchanged) -> ()) | Required _ -> File_util.copy_existing_file source destination | Optional _ -> File_util.copy_optional_existing_file source destination) artifacts; @@ -278,13 +291,11 @@ let publish ~build_dir ~ocaml_dir ~is_local ~(config : Config.t) ~has_interface | Source.Implementation when has_interface -> (* The implementation is compiled with -bs-read-cmi, so its .cmi is the one already published after compiling the interface. *) - ignore - (publish_compiler_artifacts ~artifact_dir ~ocaml_dir ~basename - [Required "cmj"; Optional "cmt"]); - Compiler_scheduler.Cmi_unchanged + publish_compiler_artifacts ~artifact_dir ~ocaml_dir ~basename + [Cmj; Optional "cmt"] | Source.Implementation -> publish_compiler_artifacts ~artifact_dir ~ocaml_dir ~basename - [Cmi; Required "cmj"; Optional "cmt"]); + [Cmi; 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); diff --git a/rewatch-ocaml/module_graph.ml b/rewatch-ocaml/module_graph.ml index f946363e61a..871a4609c06 100644 --- a/rewatch-ocaml/module_graph.ml +++ b/rewatch-ocaml/module_graph.ml @@ -291,8 +291,7 @@ let initialize ~(root_config : Config.t) ~package_plans ~compile_assets 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_cmi:(Compile_assets.published compile_assets node.key) ~last_compiled_cmt: (Compile_assets.cmt compile_assets node.key |> modified)) source_graph_nodes; @@ -301,8 +300,7 @@ let initialize ~(root_config : Config.t) ~package_plans ~compile_assets 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) + (Compile_assets.published compile_assets namespace_map.compiler_name) ~last_compiled_cmt: (Compile_assets.cmt compile_assets namespace_map.compiler_name |> modified)) diff --git a/tests/rewatch_ounit_tests/compile_assets_tests.ml b/tests/rewatch_ounit_tests/compile_assets_tests.ml index 12bc260c620..6e632407b85 100644 --- a/tests/rewatch_ounit_tests/compile_assets_tests.ml +++ b/tests/rewatch_ounit_tests/compile_assets_tests.ml @@ -109,11 +109,25 @@ let tests = check (Option.is_none (Compile_assets.cmi state "Nested")) "the compiler asset directory is scanned non-recursively"; + Unix.utimes first 100. 100.; + Unix.utimes cleanup_only 200. 200.; + Compile_assets.refresh_cmi state ~key:"Example" ~path:first; + check + (Compile_assets.published state "Example" = Some 200.) + "a CMJ newer than its CMI counts as a publication"; + Unix.utimes first 300. 300.; + Compile_assets.refresh_cmi state ~key:"Example" ~path:first; + check + (Compile_assets.published state "Example" = Some 300.) + "an older CMJ leaves the CMI publication time"; Sys.remove first; Compile_assets.refresh_cmi state ~key:"Example" ~path:first; check (Option.is_none (Compile_assets.cmi state "Example")) "refresh removes a deleted CMI"; + check + (Option.is_none (Compile_assets.published state "Example")) + "a CMJ without a CMI is not a complete publication"; let replacement_ast = Filename.concat root "Replacement.ast" in write replacement_ast "replacement"; Compile_assets.refresh_ast state ~source ~path:replacement_ast;