diff --git a/compiler/bsb/bsb_package_specs.ml b/compiler/bsb/bsb_package_specs.ml index eaf4ccee02..fe6181825f 100644 --- a/compiler/bsb/bsb_package_specs.ml +++ b/compiler/bsb/bsb_package_specs.ml @@ -130,15 +130,23 @@ let from_json suffix (x : Ext_json_types.t) : Spec_set.t = | Arr {content; _} -> from_array suffix content | _ -> Spec_set.singleton (from_json_single suffix x) -let bs_package_output = "-bs-package-output" - [@@@warning "+9"] -let package_flag ({format; in_source; suffix} : Bsb_spec_set.spec) dir = - Ext_string.inter2 bs_package_output - (Ext_string.concat5 (string_of_format format) Ext_string.single_colon - (if in_source then dir else Bsb_config.top_prefix_of_format format // dir) - Ext_string.single_colon suffix) +let package_flag ({format; in_source; suffix} : Bsb_spec_set.spec) _dir = + (* Generates three separate compiler flags that were split from the original + single "-bs-package-output module:path:suffix" flag. + + For in-source builds, pass "." as the base directory. The compiler will + extract the source subdirectory from output_prefix to construct the correct path. + For out-of-source builds, pass the lib output directory (e.g., "lib/es6"). + *) + let module_system_flag = "-bs-module-system " ^ string_of_format format in + let suffix_flag = "-bs-suffix " ^ suffix in + let output_path = + if in_source then "." else Bsb_config.top_prefix_of_format format + in + let output_flag = "-bs-package-output " ^ output_path in + module_system_flag ^ " " ^ suffix_flag ^ " " ^ output_flag (* FIXME: we should adapt it *) let package_flag_of_package_specs (package_specs : t) ~(dirname : string) : @@ -179,7 +187,15 @@ let get_list_of_output_js (package_specs : t) Ext_namespace.change_ext_ns_suffix output_file_sans_extension spec.suffix in - (if spec.in_source then Bsb_config.rev_lib_bs_prefix basename + (if spec.in_source then + (* When dir=".", name_sans_extension can be "./Module" (from Filename.concat "." "Module"). + Strip the "./" prefix to avoid generating "../.././Module.res.js" *) + let basename_clean = + if Ext_string.starts_with basename "./" then + String.sub basename 2 (String.length basename - 2) + else basename + in + Bsb_config.rev_lib_bs_prefix basename_clean else Bsb_config.lib_bs_prefix_of_format spec.format // basename) :: acc) package_specs.modules [] diff --git a/compiler/bsc/rescript_compiler_main.ml b/compiler/bsc/rescript_compiler_main.ml index ec40263bb6..0bce9ea2c4 100644 --- a/compiler/bsc/rescript_compiler_main.ml +++ b/compiler/bsc/rescript_compiler_main.ml @@ -259,10 +259,23 @@ let command_line_flags : (string * Bsc_args.spec * string) array = string_call ignore, "*internal* Set jsx mode, this is no longer used and is a no-op." ); ("-bs-jsx-preserve", set Js_config.jsx_preserve, "*internal* Preserve jsx"); + ( "-bs-module-system", + string_call (fun s -> + Js_config.default_module_system := + match Js_packages_info.module_system_of_string s with + | Some ms -> ms + | None -> + Bsc_args.bad_arg + ("Invalid module system: " ^ s + ^ ". Use: commonjs, esmodule, or es6-global")), + "*internal* Set module system: commonjs, esmodule, es6-global" ); + ( "-bs-suffix", + string_call (fun s -> Js_config.default_suffix := s), + "*internal* Set import file suffix: .js, .mjs, .cjs" ); ( "-bs-package-output", string_call Js_packages_state.update_npm_package_path, - "*internal* Set npm-output-path: [opt_module]:path, for example: \ - 'lib/cjs', 'amdjs:lib/amdjs', 'es6:lib/es6' " ); + "*internal* Set output path (when combined with -bs-module-system and \ + -bs-suffix)" ); ( "-bs-ast", unit_call (fun _ -> Js_config.binary_ast := true; diff --git a/compiler/common/js_config.ml b/compiler/common/js_config.ml index 24aa8b69f1..18ae05eede 100644 --- a/compiler/common/js_config.ml +++ b/compiler/common/js_config.ml @@ -52,6 +52,8 @@ let jsx_version = ref None let jsx_module = ref React let jsx_preserve = ref false let js_stdout = ref true +let default_module_system = ref Ext_module_system.Commonjs +let default_suffix = ref Literals.suffix_js let all_module_aliases = ref false let no_stdlib = ref false let no_export = ref false diff --git a/compiler/common/js_config.mli b/compiler/common/js_config.mli index d6f4bd8ba6..ea10080914 100644 --- a/compiler/common/js_config.mli +++ b/compiler/common/js_config.mli @@ -84,6 +84,10 @@ val jsx_preserve : bool ref val js_stdout : bool ref +val default_module_system : Ext_module_system.t ref + +val default_suffix : string ref + val all_module_aliases : bool ref val no_stdlib : bool ref diff --git a/compiler/core/js_name_of_module_id.ml b/compiler/core/js_name_of_module_id.ml index 1d6f30190c..d1ea0117c3 100644 --- a/compiler/core/js_name_of_module_id.ml +++ b/compiler/core/js_name_of_module_id.ml @@ -57,6 +57,7 @@ let get_runtime_module_path let current_info_query = Js_packages_info.query_package_infos current_package_info module_system in + (* Runtime package is pre-compiled and always uses .js suffix *) let js_file = Ext_namespace.js_name_of_modulename dep_module_id.id.name Upper Literals.suffix_js in @@ -128,7 +129,7 @@ let string_of_module_id let dep_info_query = Js_packages_info.query_package_infos dep_package_info module_system - in + in match dep_info_query, current_info_query with | Package_not_found , _ -> Bs_exception.error (Missing_ml_dependency dep_module_id.id.name) @@ -137,30 +138,329 @@ let string_of_module_id | (Package_script | Package_found _ ), Package_not_found -> assert false | Package_found ({suffix} as pkg), Package_script - -> + -> let js_file = - Ext_namespace.js_name_of_modulename dep_module_id.id.name case suffix in - pkg.pkg_rel_path // js_file + Ext_namespace.js_name_of_modulename dep_module_id.id.name case suffix in + (* External package imports: check if pkg_rel_path ends with "/." + which indicates the dependency uses in-source builds *) + if Ext_string.ends_with pkg.pkg_rel_path "/." then begin + let cmj_file = dep_module_id.id.name ^ Literals.suffix_cmj in + match Config_util.find_opt cmj_file with + | Some cmj_path -> + (* External packages store .cmj at node_modules//lib/bs//.cmj + Example: /Users/barry/Projects/great-project/node_modules/a/lib/bs/src/A-A.cmj + We extract "src" from this path. *) + let cmj_dir = Filename.dirname cmj_path in + let lib_bs_pattern = "/lib/bs/" in + let source_dir = + try + let rec find_lib_bs pos = + if pos < 0 then None + else if Ext_string.starts_with (String.sub cmj_dir pos (String.length cmj_dir - pos)) lib_bs_pattern then + Some (pos + String.length lib_bs_pattern) + else + find_lib_bs (pos - 1) + in + match find_lib_bs (String.length cmj_dir - 1) with + | Some start_idx -> + String.sub cmj_dir start_idx (String.length cmj_dir - start_idx) + | None -> "." + with Not_found -> "." + in + (* Extract package name from pkg_rel_path: "a/." -> "a" *) + let pkg_name = + String.sub pkg.pkg_rel_path 0 (String.length pkg.pkg_rel_path - 2) + in + if source_dir = "." then begin + pkg.pkg_rel_path // js_file + end else begin + let result = pkg_name // source_dir // js_file in + (* Reconstruct: "a" + "src" + "A.res.js" = "a/src/A.res.js" *) + result + end + | None -> + pkg.pkg_rel_path // js_file + end else begin + pkg.pkg_rel_path // js_file + end | Package_found ({suffix } as dep_pkg), Package_found cur_pkg -> let js_file = Ext_namespace.js_name_of_modulename dep_module_id.id.name case suffix in if Js_packages_info.same_package_by_name current_package_info dep_package_info then - Ext_path.node_rebase_file - ~from:cur_pkg.rel_path - ~to_:dep_pkg.rel_path - js_file + (* Same-package imports: both files are in the same package. + + Rewatch passes the full directory path via -bs-package-output, e.g. + "/Users/barry/Projects/great-project/src/core/intl", so rel_path contains + the actual directory. + + BSB passes ninja's $in_d variable which expands per-file to the source directory. + With the fix in bsb_package_specs.ml, this also contains the full source directory + path, e.g. "/Users/barry/Projects/great-project/src/core/intl". + + When both rel_path = ".": + - Current file in src/core/Core_TempTests.res has rel_path = "." + - Dependency in src/core/intl/Core_IntlTests.res has rel_path = "." + + Calling node_rebase_file(".", ".", "Core_IntlTests.mjs") would incorrectly + produce "./Core_IntlTests.mjs" when we need "./intl/Core_IntlTests.mjs". + + To handle this, we extract the actual source directory from the dependency's + .cmj file path. + *) + if cur_pkg.rel_path = "." && dep_pkg.rel_path = "." then + (* Both rel_path are "." - extract actual source directories from .cmj locations. + + In-source builds store .cmj files at lib/bs//.cmj + Example: /Users/barry/Projects/great-project/lib/bs/src/core/intl/Core_IntlTests.cmj + + We extract the source directory to calculate correct relative import paths. *) + let cmj_file = dep_module_id.id.name ^ Literals.suffix_cmj in + match Config_util.find_opt cmj_file with + | Some cmj_path -> + let cmj_dir = Filename.dirname cmj_path in + (* Platform-independent: look for "libbs" where is / or \\ *) + let source_dir = + try + let sep = Filename.dir_sep.[0] in + let lib_bs = "lib" ^ Filename.dir_sep ^ "bs" ^ Filename.dir_sep in + (* Find "lib/bs/" or "lib\\bs\\" in the path and extract everything after it *) + let idx = String.rindex_from cmj_dir (String.length cmj_dir - 1) sep in + let rec find_lib_bs pos = + if pos < 0 then None + else if Ext_string.starts_with (String.sub cmj_dir pos (String.length cmj_dir - pos)) lib_bs then + Some (pos + String.length lib_bs) + else + find_lib_bs (pos - 1) + in + match find_lib_bs idx with + | Some start_idx -> + (* Example: extract "src/core/intl" from ".../lib/bs/src/core/intl" or "...\\lib\\bs\\src\\core\\intl" *) + String.sub cmj_dir start_idx (String.length cmj_dir - start_idx) + | None -> cmj_dir + with Not_found -> cmj_dir + in + Ext_path.node_rebase_file + ~from:(Ext_path.absolute_cwd_path output_dir) + ~to_:(Ext_path.absolute_cwd_path source_dir) + (Filename.basename js_file) + | None -> + Ext_path.node_rebase_file + ~from:cur_pkg.rel_path + ~to_:dep_pkg.rel_path + js_file + else + (* rel_path values contain directory information, use them directly *) + Ext_path.node_rebase_file + ~from:cur_pkg.rel_path + ~to_:dep_pkg.rel_path + js_file (* TODO: we assume that both [x] and [path] could only be relative path which is guaranteed by [-bs-package-output] *) - else - if Js_packages_info.is_runtime_package dep_package_info then - get_runtime_module_path dep_module_id current_package_info module_system - else - begin match module_system with - | Commonjs | Esmodule -> - dep_pkg.pkg_rel_path // js_file + else + if Js_packages_info.is_runtime_package dep_package_info then + get_runtime_module_path dep_module_id current_package_info module_system + else begin + match module_system with + | Commonjs | Esmodule -> + (* External package imports: importing from a different package. + + When dep_pkg.rel_path = "." (dependency uses in-source builds), + pkg_rel_path becomes "package_name/." (e.g., "a/."), which would + generate invalid imports like "a/./A.res.js" instead of "a/src/A.res.js". + + We extract the actual source directory from the dependency's .cmj file + location and reconstruct the import path correctly. + *) + (* External package imports: check if pkg_rel_path ends with "/." or "\." + which indicates the dependency uses in-source builds *) + let ends_with_dot = + Ext_string.ends_with dep_pkg.pkg_rel_path "/." || + Ext_string.ends_with dep_pkg.pkg_rel_path "\\." + in + if ends_with_dot then begin + let cmj_file = dep_module_id.id.name ^ Literals.suffix_cmj in + (* Prefer lib/bs over lib/ocaml as lib/bs preserves source directory structure *) + let lib_bs_pattern = "lib" ^ Filename.dir_sep ^ "bs" ^ Filename.dir_sep in + let lib_ocaml_pattern = "lib" ^ Filename.dir_sep ^ "ocaml" ^ Filename.dir_sep in + let cmj_opt = + match Config_util.find_opt cmj_file with + | Some path when Ext_string.contain_substring path lib_bs_pattern -> + Some path + | Some ocaml_path -> + (* Found lib/ocaml, derive lib/bs path from it *) + let pkg_root = + try + let sep = Filename.dir_sep.[0] in + let rec find_lib_ocaml pos = + if pos < 0 then None + else if Ext_string.starts_with (String.sub ocaml_path pos (String.length ocaml_path - pos)) lib_ocaml_pattern then + Some (String.sub ocaml_path 0 pos) + else + let next_pos = + try String.rindex_from ocaml_path (pos - 1) sep + with Not_found -> -1 + in + find_lib_ocaml next_pos + in + find_lib_ocaml (String.length ocaml_path - 1) + with Not_found -> None + in + (match pkg_root with + | Some root -> + (* The actual cmj file is in lib/bs/src/, not lib/bs/ directly + Try a glob search to find it *) + let (//) = Filename.concat in + let rec find_in_dir dir = + let full_path = dir // cmj_file in + if Sys.file_exists full_path then Some full_path + else + try + let subdirs = Sys.readdir dir in + Array.fold_left (fun acc subdir -> + match acc with + | Some _ -> acc + | None -> + let sub_path = dir // subdir in + if Sys.is_directory sub_path then find_in_dir sub_path + else None + ) None subdirs + with _ -> None + in + let lib_bs_dir = root // "lib" // "bs" in + (match find_in_dir lib_bs_dir with + | Some bs_path -> + Some bs_path + | None -> + Some ocaml_path) + | None -> Some ocaml_path) + | None -> None + in + match cmj_opt with + | Some cmj_path -> + (* External packages store .cmj at node_modules//lib/bs//.cmj + Example: /Users/barry/Projects/rescript/node_modules/a/lib/bs/src/A-A.cmj + Or on Windows: C:\Users\barry\node_modules\a\lib\bs\src\A-A.cmj + We extract "src" from this path. + + For namespaced packages, there may be a namespace file at the root (lib/bs/A.cmj) + and the actual module in a subdirectory (lib/bs/src/A-A.cmj). We want the latter. *) + let cmj_dir = Filename.dirname cmj_path in + let sep = Filename.dir_sep.[0] in + let lib_bs_pattern = "lib" ^ Filename.dir_sep ^ "bs" ^ Filename.dir_sep in + let source_dir = + try + let idx = String.rindex_from cmj_dir (String.length cmj_dir - 1) sep in + let rec find_lib_bs pos = + if pos < 0 then None + else if Ext_string.starts_with (String.sub cmj_dir pos (String.length cmj_dir - pos)) lib_bs_pattern then + Some (pos + String.length lib_bs_pattern) + else + let next_pos = + try String.rindex_from cmj_dir (pos - 1) sep + with Not_found -> -1 + in + find_lib_bs next_pos + in + match find_lib_bs idx with + | Some start_idx -> + String.sub cmj_dir start_idx (String.length cmj_dir - start_idx) + | None -> "." + with Not_found -> "." + in + (* Extract package name from pkg_rel_path: "a/." or "a\\." -> "a" *) + let pkg_name = + String.sub dep_pkg.pkg_rel_path 0 (String.length dep_pkg.pkg_rel_path - 2) + in + (* If source_dir is ".", we found a namespace file at the root. + Try to find the actual module in subdirectories by searching lib/bs recursively. *) + let final_source_dir = + if source_dir = "." then begin + (* Derive package root from cmj_path: .../node_modules/a/lib/bs/A.cmj -> .../node_modules/a *) + try + let rec find_pkg_root path = + let parent = Filename.dirname path in + let basename = Filename.basename path in + if basename = pkg_name then Some path (* Return the path itself, not parent *) + else if parent = path then None + else find_pkg_root parent + in + match find_pkg_root cmj_path with + | Some pkg_root -> + let (//) = Filename.concat in + let lib_bs_dir = pkg_root // "lib" // "bs" in + (* Recursively search for the module file in subdirectories. + For namespaced modules, the file may be A-Namespace.cmj instead of A.cmj *) + let rec find_in_dir dir = + (* Use the original module name directly, don't try to extract from js_file *) + let module_base = dep_module_id.id.name in + (* Check both exact match (A.cmj) and namespace pattern (A-*.cmj) *) + let cmj_exact = module_base ^ Literals.suffix_cmj in + let cmj_pattern_prefix = module_base ^ "-" in + + (* First check if dir itself contains a matching file *) + let found_in_current_dir = + if dir <> lib_bs_dir then begin + try + let files = Sys.readdir dir in + Array.fold_left (fun acc file -> + match acc with + | Some _ -> acc + | None -> + if file = cmj_exact || Ext_string.starts_with file cmj_pattern_prefix then begin + if Ext_string.ends_with file Literals.suffix_cmj then begin + let full_path = dir // file in + if Sys.file_exists full_path && not (Sys.is_directory full_path) then + (* Found in a subdirectory, extract relative path from lib/bs/ *) + let rel_from_lib_bs = String.sub dir (String.length lib_bs_dir + 1) (String.length dir - String.length lib_bs_dir - 1) in + Some rel_from_lib_bs + else None + end else None + end else None + ) None files + with _ -> None + end else None + in + + match found_in_current_dir with + | Some _ -> found_in_current_dir + | None -> + (* Not found in current dir, search subdirectories *) + try + let subdirs = Sys.readdir dir in + Array.fold_left (fun acc subdir -> + match acc with + | Some _ -> acc + | None -> + let sub_path = dir // subdir in + if Sys.is_directory sub_path then find_in_dir sub_path + else None + ) None subdirs + with _ -> None + in + (match find_in_dir lib_bs_dir with + | Some subdir -> subdir + | None -> ".") + | None -> "." + with _ -> "." + end else + source_dir + in + if final_source_dir = "." then + (* Still couldn't find it, use default *) + dep_pkg.pkg_rel_path // js_file + else begin + let result = pkg_name // final_source_dir // js_file in + (* Reconstruct: "a" + "src" + "A.res.js" = "a/src/A.res.js" *) + result + end + | None -> + dep_pkg.pkg_rel_path // js_file + end else begin + dep_pkg.pkg_rel_path // js_file + end (* Note we did a post-processing when working on Windows *) | Es6_global -> @@ -177,8 +477,9 @@ let string_of_module_id end | Package_script, Package_script -> + (* Use configured suffix instead of hardcoded .js *) let js_file = - Ext_namespace.js_name_of_modulename dep_module_id.id.name case Literals.suffix_js in + Ext_namespace.js_name_of_modulename dep_module_id.id.name case !Js_config.default_suffix in match Config_util.find_opt js_file with | Some file -> let basename = Filename.basename file in diff --git a/compiler/core/js_packages_info.ml b/compiler/core/js_packages_info.ml index d181b6e086..7985f591a9 100644 --- a/compiler/core/js_packages_info.ml +++ b/compiler/core/js_packages_info.ml @@ -192,13 +192,21 @@ let add_npm_package_path (packages_info : t) (s : string) : t = in let m = match Ext_string.split ~keep_empty:true s ':' with - | [path] -> {module_system = Esmodule; path; suffix = Literals.suffix_js} + (* NEW: Just path - use configured module system and suffix *) + | [path] -> + { + module_system = !Js_config.default_module_system; + path; + suffix = !Js_config.default_suffix; + } + (* OLD: module_system:path - use configured suffix *) | [module_system; path] -> { module_system = handle_module_system module_system; path; - suffix = Literals.suffix_js; + suffix = !Js_config.default_suffix; } + (* OLD: Full format - all explicit *) | [module_system; path; suffix] -> {module_system = handle_module_system module_system; path; suffix} | _ -> Bsc_args.bad_arg ("invalid npm package path: " ^ s) diff --git a/compiler/core/js_packages_info.mli b/compiler/core/js_packages_info.mli index 6e5c551df8..05f7bcada1 100644 --- a/compiler/core/js_packages_info.mli +++ b/compiler/core/js_packages_info.mli @@ -50,6 +50,9 @@ val is_empty : t -> bool val dump_packages_info : Format.formatter -> t -> unit +val module_system_of_string : string -> module_system option +(** Parse module system from string (commonjs, esmodule, es6, es6-global) *) + val add_npm_package_path : t -> string -> t (** used by command line option e.g [-bs-package-output commonjs:xx/path] diff --git a/compiler/core/lam_compile_main.ml b/compiler/core/lam_compile_main.ml index bd4191b0e7..b32275f077 100644 --- a/compiler/core/lam_compile_main.ml +++ b/compiler/core/lam_compile_main.ml @@ -292,7 +292,8 @@ let lambda_as_module : unit = let package_info = Js_packages_state.get_packages_info () in if Js_packages_info.is_empty package_info && !Js_config.js_stdout then begin - Js_dump_program.dump_deps_program ~output_prefix Commonjs (lambda_output) stdout + (* Use configured module system instead of hardcoded Commonjs *) + Js_dump_program.dump_deps_program ~output_prefix !Js_config.default_module_system (lambda_output) stdout end else Js_packages_info.iter package_info (fun {module_system; path; suffix} -> let output_chan chan = @@ -303,12 +304,40 @@ let lambda_as_module let basename = Ext_namespace.change_ext_ns_suffix (Filename.basename output_prefix) suffix in + (* Construct target path: + - For bsb (path="."): extract source subdir from output_prefix + - For rewatch: path already contains full directory from file_path.parent() + - basename is the final filename *) let target_file = - (Lazy.force Ext_path.package_dir // - path // - basename - (* #913 only generate little-case js file *) - ) in + (* Check if path is a base output directory (bsb mode) vs full path (rewatch mode) + Use starts_with to handle both Unix (lib/es6) and Windows (lib\es6) paths *) + let is_base_path = + path = "." || + Ext_string.starts_with path "lib" && + (Ext_string.contain_substring path "bs" || + Ext_string.contain_substring path "js" || + Ext_string.contain_substring path "es6") + in + if is_base_path then + (* Legacy bsb mode: path is base dir, extract source subdir from output_prefix *) + let source_subdir = Filename.dirname output_prefix in + (* When source_subdir is ".", don't include it in the path to avoid "././" *) + if source_subdir = "." then + (Lazy.force Ext_path.package_dir // + path // + basename) + else + (Lazy.force Ext_path.package_dir // + path // + source_subdir // + basename) + else + (* Rewatch mode: path already contains full directory *) + (Lazy.force Ext_path.package_dir // + path // + basename + (* #913 only generate little-case js file *) + ) in (if not !Clflags.dont_write_files then Ext_pervasives.with_file_as_chan target_file output_chan ); diff --git a/compiler/core/lam_compile_primitive.ml b/compiler/core/lam_compile_primitive.ml index e7c377e97a..17761e7376 100644 --- a/compiler/core/lam_compile_primitive.ml +++ b/compiler/core/lam_compile_primitive.ml @@ -46,7 +46,8 @@ let get_module_system () = let package_info = Js_packages_state.get_packages_info () in let module_system = if Js_packages_info.is_empty package_info && !Js_config.js_stdout then - [Ext_module_system.Commonjs] + (* Use configured module system instead of hardcoded Commonjs *) + [!Js_config.default_module_system] else Js_packages_info.map package_info (fun {module_system} -> module_system) in diff --git a/rewatch/src/build.rs b/rewatch/src/build.rs index f141280832..e9e40cbe87 100644 --- a/rewatch/src/build.rs +++ b/rewatch/src/build.rs @@ -16,6 +16,7 @@ use crate::helpers::emojis::*; use crate::helpers::{self}; use crate::project_context::ProjectContext; use crate::{config, sourcedirs}; +use ahash::AHashSet; use anyhow::{Result, anyhow}; use build_types::*; use console::style; @@ -507,3 +508,203 @@ pub fn build( } } } + +/// Compile a single ReScript file and return its JavaScript output. +/// +/// This function performs a targeted one-shot compilation: +/// 1. Initializes build state (reusing cached artifacts from previous builds) +/// 2. Finds the target module from the file path +/// 3. Calculates the dependency closure (all transitive dependencies) +/// 4. Marks target + dependencies as dirty for compilation +/// 5. Runs incremental build to compile only what's needed +/// 6. Reads and returns the generated JavaScript file +/// +/// # Workflow +/// Unlike the watch mode which expands UPWARD to dependents when a file changes, +/// this expands DOWNWARD to dependencies to ensure everything needed is compiled. +/// +/// # Example +/// If compiling `App.res` which imports `Component.res` which imports `Utils.res`: +/// - Dependency closure: {Utils, Component, App} +/// - Compilation order (via wave algorithm): Utils → Component → App +/// +/// # Errors +/// Returns error if: +/// - File doesn't exist or isn't part of the project +/// - Compilation fails (parse errors, type errors, etc.) +/// - Generated JavaScript file cannot be found +pub fn compile_one( + target_file: &Path, + project_root: &Path, + plain_output: bool, + warn_error: Option, +) -> Result { + use std::fs; + + // Step 1: Initialize build state + // This leverages any existing .ast/.cmi files from previous builds + let mut build_state = initialize_build( + None, + &None, // no filter + false, // no progress output (keep stderr clean) + project_root, + plain_output, + warn_error, + )?; + + // Step 2: Find target module from file path + let target_module_name = find_module_for_file(&build_state, target_file) + .ok_or_else(|| anyhow!("File not found in project: {}", target_file.display()))?; + + // Step 3: Mark only the target file as parse_dirty + // This ensures we parse the latest version of the target file + if let Some(module) = build_state.modules.get_mut(&target_module_name) { + if let SourceType::SourceFile(source_file) = &mut module.source_type { + source_file.implementation.parse_dirty = true; + if let Some(interface) = &mut source_file.interface { + interface.parse_dirty = true; + } + } + } + + // Step 4: Get dependency closure (downward traversal) + // Unlike compile universe (upward to dependents), we need all dependencies + let dependency_closure = get_dependency_closure(&target_module_name, &build_state); + + // Step 5: Mark all dependencies as compile_dirty + for module_name in &dependency_closure { + if let Some(module) = build_state.modules.get_mut(module_name) { + module.compile_dirty = true; + } + } + + // Step 6: Run incremental build + // The wave compilation algorithm will compile dependencies first, then the target + incremental_build( + &mut build_state, + None, + false, // not initial build + false, // no progress output + true, // only incremental (no cleanup step) + false, // no sourcedirs + plain_output, + ) + .map_err(|e| anyhow!("Compilation failed: {}", e))?; + + // Step 7: Find and read the generated JavaScript file + let js_path = get_js_output_path(&build_state, &target_module_name, target_file)?; + let js_content = fs::read_to_string(&js_path) + .map_err(|e| anyhow!("Failed to read generated JS file {}: {}", js_path.display(), e))?; + + Ok(js_content) +} + +/// Find the module name for a given file path by searching through all modules. +/// +/// This performs a linear search through the build state's modules to match +/// the canonical file path. Returns the module name if found. +fn find_module_for_file(build_state: &BuildCommandState, target_file: &Path) -> Option { + let canonical_target = target_file.canonicalize().ok()?; + + for (module_name, module) in &build_state.modules { + if let SourceType::SourceFile(source_file) = &module.source_type { + let package = build_state.packages.get(&module.package_name)?; + + // Check implementation file + let impl_path = package.path.join(&source_file.implementation.path); + if impl_path.canonicalize().ok().as_ref() == Some(&canonical_target) { + return Some(module_name.clone()); + } + + // Check interface file if present + if let Some(interface) = &source_file.interface { + let iface_path = package.path.join(&interface.path); + if iface_path.canonicalize().ok().as_ref() == Some(&canonical_target) { + return Some(module_name.clone()); + } + } + } + } + + None +} + +/// Calculate the transitive closure of all dependencies for a given module. +/// +/// This performs a downward traversal (dependencies, not dependents): +/// - Module A depends on B and C +/// - B depends on D +/// - Result: {A, B, C, D} +/// +/// This is the opposite of the "compile universe" which expands upward to dependents. +fn get_dependency_closure(module_name: &str, build_state: &BuildState) -> AHashSet { + let mut closure = AHashSet::new(); + let mut to_process = vec![module_name.to_string()]; + + while let Some(current) = to_process.pop() { + if !closure.contains(¤t) { + closure.insert(current.clone()); + + if let Some(module) = build_state.get_module(¤t) { + // Add all dependencies to process queue + for dep in &module.deps { + if !closure.contains(dep) { + to_process.push(dep.clone()); + } + } + } + } + } + + closure +} + +/// Get the path to the generated JavaScript file for a module. +/// +/// Respects the package's configuration for output location and format: +/// - in-source: JS file next to the .res file +/// - out-of-source: JS file in lib/js or lib/es6 +/// - Uses first package spec to determine .js vs .mjs extension +fn get_js_output_path( + build_state: &BuildCommandState, + module_name: &str, + _original_file: &Path, +) -> Result { + let module = build_state + .get_module(module_name) + .ok_or_else(|| anyhow!("Module not found: {}", module_name))?; + + let package = build_state + .get_package(&module.package_name) + .ok_or_else(|| anyhow!("Package not found: {}", module.package_name))?; + + let root_config = build_state.get_root_config(); + let package_specs = root_config.get_package_specs(); + let package_spec = package_specs + .first() + .ok_or_else(|| anyhow!("No package specs configured"))?; + + let suffix = root_config.get_suffix(package_spec); + + if let SourceType::SourceFile(source_file) = &module.source_type { + let source_path = &source_file.implementation.path; + + if package_spec.in_source { + // in-source: JS file next to source file + let js_file = source_path.with_extension(&suffix[1..]); // remove leading dot + Ok(package.path.join(js_file)) + } else { + // out-of-source: in lib/js or lib/es6 + let base_path = if package_spec.is_common_js() { + package.get_js_path() + } else { + package.get_es6_path() + }; + + let js_file = source_path.with_extension(&suffix[1..]); + Ok(base_path.join(js_file)) + } + } else { + Err(anyhow!("Cannot get JS output for non-source module")) + } +} diff --git a/rewatch/src/build/compile.rs b/rewatch/src/build/compile.rs index 8048764f09..2869c044c9 100644 --- a/rewatch/src/build/compile.rs +++ b/rewatch/src/build/compile.rs @@ -493,25 +493,25 @@ pub fn compiler_args( specs .iter() .flat_map(|spec| { + // Pass module system, suffix, and output path as separate flags vec![ + "-bs-module-system".to_string(), + spec.module.clone(), + "-bs-suffix".to_string(), + root_config.get_suffix(spec), "-bs-package-output".to_string(), - format!( - "{}:{}:{}", - spec.module, - if spec.in_source { - file_path.parent().unwrap().to_str().unwrap().to_string() - } else { - Path::new("lib") - .join(Path::join( - Path::new(&spec.get_out_of_source_dir()), - file_path.parent().unwrap(), - )) - .to_str() - .unwrap() - .to_string() - }, - root_config.get_suffix(spec), - ), + if spec.in_source { + file_path.parent().unwrap().to_str().unwrap().to_string() + } else { + Path::new("lib") + .join(Path::join( + Path::new(&spec.get_out_of_source_dir()), + file_path.parent().unwrap(), + )) + .to_str() + .unwrap() + .to_string() + }, ] }) .collect() diff --git a/rewatch/src/cli.rs b/rewatch/src/cli.rs index 3b4604ce54..bd3994ba13 100644 --- a/rewatch/src/cli.rs +++ b/rewatch/src/cli.rs @@ -462,6 +462,14 @@ pub enum Command { #[command()] path: String, }, + /// Compile a single file and output JavaScript to stdout + CompileFile { + /// Path to a ReScript source file (.res or .resi) + path: String, + + #[command(flatten)] + warn_error: WarnErrorArg, + }, } impl Deref for FolderArg { diff --git a/rewatch/src/main.rs b/rewatch/src/main.rs index ba74dbd393..1fdfa6069e 100644 --- a/rewatch/src/main.rs +++ b/rewatch/src/main.rs @@ -3,7 +3,7 @@ use console::Term; use log::LevelFilter; use std::{io::Write, path::Path}; -use rescript::{build, cli, cmd, format, lock, watcher}; +use rescript::{build, cli, cmd, format, helpers, lock, watcher}; fn main() -> Result<()> { let cli = cli::parse_with_default().unwrap_or_else(|err| err.exit()); @@ -37,6 +37,28 @@ fn main() -> Result<()> { println!("{}", build::get_compiler_args(Path::new(&path))?); std::process::exit(0); } + cli::Command::CompileFile { path, warn_error } => { + // Find project root by walking up from file path (same as CompilerArgs command) + let file_path = Path::new(&path); + let project_root = helpers::get_abs_path( + &helpers::get_nearest_config(file_path).expect("Couldn't find package root (rescript.json)"), + ); + + let _lock = get_lock(project_root.to_str().unwrap()); + + match build::compile_one(file_path, &project_root, plain_output, (*warn_error).clone()) { + Ok(js_output) => { + // Output JS to stdout (clean for piping) + print!("{js_output}"); + std::process::exit(0) + } + Err(e) => { + // Errors go to stderr + eprintln!("{e}"); + std::process::exit(1) + } + } + } cli::Command::Build(build_args) => { let _lock = get_lock(&build_args.folder); diff --git a/rewatch/tests/compile-one.sh b/rewatch/tests/compile-one.sh new file mode 100755 index 0000000000..4e9c562912 --- /dev/null +++ b/rewatch/tests/compile-one.sh @@ -0,0 +1,60 @@ +#!/bin/bash +cd $(dirname $0) +source "./utils.sh" +cd ../testrepo + +bold "Test: compile-file command should output JS to stdout" + +# Build first to ensure artifacts exist +error_output=$(rewatch build 2>&1) +if [ $? -ne 0 ]; then + error "Error building repo" + printf "%s\n" "$error_output" >&2 + exit 1 +fi + +# Test 1: Basic compilation - stdout should contain valid JavaScript +bold "Test: Compile outputs valid JavaScript" +stdout=$(rewatch compile-file packages/main/src/Main.res 2>/dev/null) +if [ $? -ne 0 ]; then + error "Error compiling packages/main/src/Main.res" + exit 1 +fi + +# Check stdout contains JS (look for common JS patterns) +if echo "$stdout" | grep -q "export\|function\|import" ; then + success "compile outputs JavaScript to stdout" +else + error "compile stdout doesn't look like JavaScript" + echo "$stdout" + exit 1 +fi + +# Test 2: Compilation from subdirectory should work +bold "Test: Compile works from subdirectory" +pushd packages/main > /dev/null +stdout=$("$REWATCH_EXECUTABLE" compile-file src/Main.res 2>/dev/null) +if [ $? -eq 0 ]; then + success "compile works from subdirectory" +else + error "compile failed from subdirectory" + popd > /dev/null + exit 1 +fi +popd > /dev/null + +# Test 3: Errors should go to stderr, not stdout +bold "Test: Errors go to stderr, not stdout" +stdout=$(rewatch compile-file packages/main/src/NonExistent.res 2>/dev/null) +stderr=$(rewatch compile-file packages/main/src/NonExistent.res 2>&1 >/dev/null) +if [ -z "$stdout" ] && [ -n "$stderr" ]; then + success "Errors correctly sent to stderr" +else + error "Errors not correctly handled" + echo "stdout: $stdout" + echo "stderr: $stderr" + exit 1 +fi + +success "All compile-one tests passed" + diff --git a/rewatch/tests/suite.sh b/rewatch/tests/suite.sh index 08af67d5c3..82d70a4446 100755 --- a/rewatch/tests/suite.sh +++ b/rewatch/tests/suite.sh @@ -53,4 +53,4 @@ else exit 1 fi -./compile.sh && ./watch.sh && ./lock.sh && ./suffix.sh && ./format.sh && ./clean.sh && ./experimental.sh && ./experimental-invalid.sh && ./compiler-args.sh +./compile.sh && ./watch.sh && ./lock.sh && ./suffix.sh && ./format.sh && ./clean.sh && ./experimental.sh && ./experimental-invalid.sh && ./compiler-args.sh && ./compile-one.sh