diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ddebb640f4..6ea92cd3426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ #### :rocket: New Feature +- Add `rescript compile-file` command for one-shot compilation of a single ReScript file to JavaScript, outputting to stdout. Supports `--module-format` flag to select output format when multiple package-specs are configured. https://github.com/rescript-lang/rescript/pull/8002 + #### :bug: Bug fix - Reanalyze: fix reactive/server stale results when cross-file references change without changing dead declarations (non-transitive mode). https://github.com/rescript-lang/rescript/pull/8173 diff --git a/compiler/bsc/rescript_compiler_main.ml b/compiler/bsc/rescript_compiler_main.ml index ec40263bb67..0bce9ea2c40 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 24aa8b69f13..18ae05eedec 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 d6f4bd8ba60..ea10080914c 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 1d6f30190c7..6f63dc44146 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 @@ -177,8 +178,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 d181b6e0869..7985f591a9d 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 6e5c551df88..05f7bcada18 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 5871d643ebd..204b9a41d05 100644 --- a/compiler/core/lam_compile_main.ml +++ b/compiler/core/lam_compile_main.ml @@ -1,5 +1,5 @@ (* Copyright (C) 2015 - 2016 Bloomberg Finance L.P. - * Copyright (C) 2017 - Hongbo Zhang, Authors of ReScript + * Copyright (C) 2017 - Hongbo Zhang, Authors of ReScript * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or @@ -17,7 +17,7 @@ * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. - * + * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *) @@ -33,30 +33,30 @@ (* module S = Js_stmt_make *) -let compile_group output_prefix (meta : Lam_stats.t) - (x : Lam_group.t) : Js_output.t = - match x with - (* +let compile_group output_prefix (meta : Lam_stats.t) + (x : Lam_group.t) : Js_output.t = + match x with + (* We need 2. [E.builtin_dot] for javascript builtin 3. [E.mldot] *) - (* ATTENTION: check {!Lam_compile_global} for consistency *) - (* Special handling for values in [Pervasives] *) - (* - we delegate [stdout, stderr, and stdin] into [caml_io] module, - the motivation is to help dead code eliminatiion, it's helpful - to make those parts pure (not a function call), then it can be removed - if unused - *) + (* ATTENTION: check {!Lam_compile_global} for consistency *) + (* Special handling for values in [Pervasives] *) + (* + we delegate [stdout, stderr, and stdin] into [caml_io] module, + the motivation is to help dead code eliminatiion, it's helpful + to make those parts pure (not a function call), then it can be removed + if unused + *) (* QUICK hack to make hello world example nicer, - Note the arity of [print_endline] is already analyzed before, + Note the arity of [print_endline] is already analyzed before, so it should be safe *) - | Single (kind, id, lam) -> + | Single (kind, id, lam) -> (* let lam = Optimizer.simplify_lets [] lam in *) (* can not apply again, it's wrong USE it with care*) (* ([Js_stmt_make.comment (Gen_of_env.query_type id env )], None) ++ *) @@ -65,12 +65,12 @@ let compile_group output_prefix (meta : Lam_stats.t) meta } lam - | Recursive id_lams -> + | Recursive id_lams -> Lam_compile.compile_recursive_lets ~output_prefix - { continuation = EffectCall Not_tail; + { continuation = EffectCall Not_tail; jmp_table = Lam_compile_context.empty_handler_map; meta - } + } id_lams | Nop lam -> (* TODO: Side effect callls, log and see statistics *) Lam_compile.compile_lambda ~output_prefix {continuation = EffectCall Not_tail; @@ -81,33 +81,33 @@ let compile_group output_prefix (meta : Lam_stats.t) ;; (** Also need analyze its depenency is pure or not *) -let no_side_effects (rest : Lam_group.t list) : string option = - Ext_list.find_opt rest (fun x -> - match x with - | Single(kind,id,body) -> - begin - match kind with - | Strict | Variable -> - if not @@ Lam_analysis.no_side_effects body +let no_side_effects (rest : Lam_group.t list) : string option = + Ext_list.find_opt rest (fun x -> + match x with + | Single(kind,id,body) -> + begin + match kind with + | Strict | Variable -> + if not @@ Lam_analysis.no_side_effects body then Some (Printf.sprintf "%s" id.name) else None | _ -> None end - | Recursive bindings -> - Ext_list.find_opt bindings (fun (id,lam) -> - if not @@ Lam_analysis.no_side_effects lam + | Recursive bindings -> + Ext_list.find_opt bindings (fun (id,lam) -> + if not @@ Lam_analysis.no_side_effects lam then Some (Printf.sprintf "%s" id.Ident.name ) else None ) - | Nop lam -> - if not @@ Lam_analysis.no_side_effects lam - then + | Nop lam -> + if not @@ Lam_analysis.no_side_effects lam + then (* (Lam_util.string_of_lambda lam) *) Some "" else None (* TODO :*)) -let _d = fun s lam -> +let _d = fun s lam -> #ifndef RELEASE Lam_util.dump s lam ; Ext_log.dwarn ~__POS__ "START CHECKING PASS %s@." s; @@ -116,46 +116,46 @@ let _d = fun s lam -> #endif lam -let _j = Js_pass_debug.dump +let _j = Js_pass_debug.dump -(** Actually simplify_lets is kind of global optimization since it requires you to know whether - it's used or not +(** Actually simplify_lets is kind of global optimization since it requires you to know whether + it's used or not *) -let compile +let compile (output_prefix : string) export_idents - (lam : Lambda.lambda) = - let export_ident_sets = Set_ident.of_list export_idents in + (lam : Lambda.lambda) = + let export_ident_sets = Set_ident.of_list export_idents in (* To make toplevel happy - reentrant for js-demo *) - let () = + let () = #ifndef RELEASE - Ext_list.iter export_idents + Ext_list.iter export_idents (fun id -> Ext_log.dwarn ~__POS__ "export idents: %s/%d" id.name id.stamp) ; -#endif +#endif Lam_compile_env.reset () ; - in - let lam, may_required_modules = Lam_convert.convert export_ident_sets lam in + in + let lam, may_required_modules = Lam_convert.convert export_ident_sets lam in let lam = _d "initial" lam in let lam = Lam_pass_deep_flatten.deep_flatten lam in let lam = _d "flatten0" lam in - let meta : Lam_stats.t = - Lam_stats.make + let meta : Lam_stats.t = + Lam_stats.make ~export_idents - ~export_ident_sets in - let () = Lam_pass_collect.collect_info meta lam in - let lam = - let lam = + ~export_ident_sets in + let () = Lam_pass_collect.collect_info meta lam in + let lam = + let lam = lam |> _d "flattern1" |> Lam_pass_exits.simplify_exits |> _d "simplyf_exits" - |> (fun lam -> Lam_pass_collect.collect_info meta lam; -#ifndef RELEASE - let () = - Ext_log.dwarn ~__POS__ "Before simplify_alias: %a@." Lam_stats.print meta in -#endif + |> (fun lam -> Lam_pass_collect.collect_info meta lam; +#ifndef RELEASE + let () = + Ext_log.dwarn ~__POS__ "Before simplify_alias: %a@." Lam_stats.print meta in +#endif lam) |> Lam_pass_remove_alias.simplify_alias meta |> _d "simplify_alias" @@ -167,43 +167,43 @@ let compile let lam = Lam_pass_remove_alias.simplify_alias meta lam in let lam = Lam_pass_deep_flatten.deep_flatten lam in let () = Lam_pass_collect.collect_info meta lam in - let lam = + let lam = lam |> _d "alpha_before" |> Lam_pass_alpha_conversion.alpha_conversion meta |> _d "alpha_after" - |> Lam_pass_exits.simplify_exits in + |> Lam_pass_exits.simplify_exits in let () = Lam_pass_collect.collect_info meta lam in lam |> _d "simplify_alias_before" - |> Lam_pass_remove_alias.simplify_alias meta + |> Lam_pass_remove_alias.simplify_alias meta |> _d "alpha_conversion" |> Lam_pass_alpha_conversion.alpha_conversion meta |> _d "before-simplify_lets" (* we should investigate a better way to put different passes : )*) - |> Lam_pass_lets_dce.simplify_lets + |> Lam_pass_lets_dce.simplify_lets |> _d "before-simplify-exits" - (* |> (fun lam -> Lam_pass_collect.collect_info meta lam + (* |> (fun lam -> Lam_pass_collect.collect_info meta lam ; Lam_pass_remove_alias.simplify_alias meta lam) *) (* |> Lam_group_pass.scc_pass |> _d "scc" *) |> Lam_pass_exits.simplify_exits |> _d "simplify_lets" #ifndef RELEASE - |> (fun lam -> - let () = - Ext_log.dwarn ~__POS__ "Before coercion: %a@." Lam_stats.print meta in + |> (fun lam -> + let () = + Ext_log.dwarn ~__POS__ "Before coercion: %a@." Lam_stats.print meta in Lam_check.check !Location.input_name lam - ) -#endif + ) +#endif in - let ({Lam_coercion.groups = groups } as coerced_input , meta) = + let ({Lam_coercion.groups = groups } as coerced_input , meta) = Lam_coercion.coerce_and_group_big_lambda meta lam - in + in #ifndef RELEASE let () = @@ -213,33 +213,33 @@ let () = Ext_filename.new_extension !Location.input_name ".lambda" in Ext_fmt.with_file_as_pp f begin fun fmt -> Format.pp_print_list ~pp_sep:Format.pp_print_newline - Lam_group.pp_group fmt (coerced_input.groups) + Lam_group.pp_group fmt (coerced_input.groups) end; in -#endif +#endif let maybe_pure = no_side_effects groups in #ifndef RELEASE -let () = Ext_log.dwarn ~__POS__ "\n@[[TIME:]Pre-compile: %f@]@." (Sys.time () *. 1000.) in -#endif -let body = +let () = Ext_log.dwarn ~__POS__ "\n@[[TIME:]Pre-compile: %f@]@." (Sys.time () *. 1000.) in +#endif +let body = Ext_list.map groups (fun group -> compile_group output_prefix meta group) |> Js_output.concat |> Js_output.output_as_block in #ifndef RELEASE -let () = Ext_log.dwarn ~__POS__ "\n@[[TIME:]Post-compile: %f@]@." (Sys.time () *. 1000.) in -#endif +let () = Ext_log.dwarn ~__POS__ "\n@[[TIME:]Post-compile: %f@]@." (Sys.time () *. 1000.) in +#endif (* The file is not big at all compared with [cmo] *) (* Ext_marshal.to_file (Ext_path.chop_extension filename ^ ".mj") js; *) -let meta_exports = meta.exports in -let export_set = Set_ident.of_list meta_exports in -let js : J.program = - { - exports = meta_exports ; - export_set; +let meta_exports = meta.exports in +let export_set = Set_ident.of_list meta_exports in +let js : J.program = + { + exports = meta_exports ; + export_set; block = body} in -js +js |> _j "initial" |> Js_pass_flatten.program |> _j "flatten" @@ -254,67 +254,70 @@ js |> (fun js -> ignore @@ Js_pass_scope.program js ; js ) |> Js_shake.shake_program |> _j "shake" -|> ( fun (program: J.program) -> - let external_module_ids : Lam_module_ident.t list = +|> ( fun (program: J.program) -> + let external_module_ids : Lam_module_ident.t list = if !Js_config.all_module_aliases then [] else - let hard_deps = - Js_fold_basic.calculate_hard_dependencies program.block in - Lam_compile_env.populate_required_modules - may_required_modules hard_deps ; + let hard_deps = + Js_fold_basic.calculate_hard_dependencies program.block in + Lam_compile_env.populate_required_modules + may_required_modules hard_deps ; Ext_list.sort_via_array (Lam_module_ident.Hash_set.to_list hard_deps) (fun id1 id2 -> Ext_string.compare (Lam_module_ident.name id1) (Lam_module_ident.name id2) - ) + ) in Warnings.check_fatal(); - let effect_ = + let effect_ = Lam_stats_export.get_dependent_module_effect - maybe_pure external_module_ids in - let v : Js_cmj_format.t = - Lam_stats_export.export_to_cmj - meta - effect_ - coerced_input.export_map + maybe_pure external_module_ids in + let v : Js_cmj_format.t = + Lam_stats_export.export_to_cmj + meta + effect_ + coerced_input.export_map (if Ext_char.is_lower_case (Filename.basename output_prefix).[0] then Little else Upper) in (if not !Clflags.dont_write_files then - Js_cmj_format.to_file + Js_cmj_format.to_file ~check_exists:(not !Js_config.force_cmj) (output_prefix ^ Literals.suffix_cmj) v); - {J.program = program ; side_effect = effect_ ; modules = external_module_ids } + {J.program = program ; side_effect = effect_ ; modules = external_module_ids } ) ;; -let (//) = Filename.concat +let (//) = Filename.concat -let lambda_as_module +let lambda_as_module (lambda_output : J.deps_program) (output_prefix : string) - : 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 + : 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 + (* Stdout mode: used by "rewatch compile-file" for one-shot compilation. + No -bs-package-output was provided, so we output JS to stdout using + the module system configured via -bs-module-system (or default). *) + 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 = + Js_packages_info.iter package_info (fun {module_system; path; suffix} -> + let output_chan chan = Js_dump_program.dump_deps_program ~output_prefix - module_system + module_system (lambda_output) chan in - let basename = + let basename = Ext_namespace.change_ext_ns_suffix (Filename.basename output_prefix) suffix in - let target_file = + let target_file = (Lazy.force Ext_path.package_dir // path // basename (* #913 only generate little-case js file *) - ) in - (if not !Clflags.dont_write_files then + ) in + (if not !Clflags.dont_write_files then Ext_pervasives.with_file_as_chan target_file output_chan ); - if !Warnings.has_warnings then begin + if !Warnings.has_warnings then begin Warnings.has_warnings := false ; #ifndef BROWSER (* 5206: When there were warnings found during the compilation, we want the file @@ -323,18 +326,18 @@ let lambda_as_module (Do *not* set the timestamp of the JS output file instead as that does not play well with every bundler.) *) let ast_file = output_prefix ^ Literals.suffix_ast in - if Sys.file_exists ast_file then begin + if Sys.file_exists ast_file then begin Bs_hash_stubs.set_as_old_file ast_file - end -#endif - end + end +#endif + end ) -(* We can use {!Env.current_unit = "Pervasives"} to tell if it is some specific module, - We need handle some definitions in standard libraries in a special way, most are io specific, +(* We can use {!Env.current_unit = "Pervasives"} to tell if it is some specific module, + We need handle some definitions in standard libraries in a special way, most are io specific, includes {!Pervasives.stdin, Pervasives.stdout, Pervasives.stderr} - However, use filename instead of {!Env.current_unit} is more honest, since node-js module system is coupled with the file name + However, use filename instead of {!Env.current_unit} is more honest, since node-js module system is coupled with the file name *) diff --git a/compiler/core/lam_compile_primitive.ml b/compiler/core/lam_compile_primitive.ml index e7c377e97a7..17761e73763 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 c5eb31b0924..1d595037e66 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::{Context, Result, anyhow}; use build_types::*; use console::style; @@ -102,6 +103,7 @@ pub fn get_compiler_args(rescript_file_path: &Path) -> Result { is_type_dev, true, None, // No warn_error_override for compiler-args command + compile::OutputMode::ToFile, )?; let result = serde_json::to_string_pretty(&CompilerArgs { @@ -548,3 +550,239 @@ 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 dependencies (excluding target) as dirty for compilation +/// 5. Runs incremental build to compile dependencies (ensures .cmi files exist) +/// 6. Compiles target file directly to stdout (no file write) +/// +/// # 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. +/// The target file itself is compiled separately with output to stdout. +/// +/// # Example +/// If compiling `App.res` which imports `Component.res` which imports `Utils.res`: +/// - Dependency closure: {Utils, Component, App} +/// - Dependencies compiled to disk: Utils, Component (for .cmi files) +/// - Target compiled to stdout: App +/// +/// # Errors +/// Returns error if: +/// - File doesn't exist or isn't part of the project +/// - Compilation fails (parse errors, type errors, etc.) +pub fn compile_one( + target_file: &Path, + project_root: &Path, + plain_output: bool, + warn_error: Option, + module_format: Option, +) -> Result { + // 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.clone(), + )?; + + // Determine the module format for output + let root_config = build_state.get_root_config(); + let package_specs = root_config.get_package_specs(); + let module_format_str = get_module_format(&package_specs, &module_format)?; + + // 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) + && 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 dependencies (excluding target) as compile_dirty + // The target will be compiled separately to stdout + for module_name in &dependency_closure { + if module_name != &target_module_name + && let Some(module) = build_state.modules.get_mut(module_name) + { + module.compile_dirty = true; + } + } + + // Step 6: Run incremental build for dependencies only + // This ensures all .cmi files exist for the target's imports + 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: Compile the target file to stdout + let module = build_state + .get_module(&target_module_name) + .ok_or_else(|| anyhow!("Module not found: {}", target_module_name))?; + + let package = build_state + .get_package(&module.package_name) + .ok_or_else(|| anyhow!("Package not found: {}", module.package_name))?; + + // Get the AST path for the target module + let ast_path = get_ast_path(&build_state, &target_module_name)?; + + compile::compile_file_to_stdout( + package, + &ast_path, + module, + &build_state, + warn_error, + &module_format_str, + ) +} + +/// Determine the module format to use for stdout output. +/// +/// If module_format is specified via --module-format, it must match one of the +/// configured package-specs (since dependencies are compiled to those formats). +/// If not specified, use the first package-spec's format (warn if multiple exist). +fn get_module_format( + package_specs: &[config::PackageSpec], + module_format: &Option, +) -> Result { + if package_specs.is_empty() { + return Err(anyhow!("No package-specs configured in rescript.json")); + } + + match module_format { + Some(format) => { + // Must match a configured package-spec (dependencies are compiled to those formats) + if package_specs.iter().any(|spec| spec.module == *format) { + Ok(format.clone()) + } else { + let available: Vec<&str> = package_specs.iter().map(|s| s.module.as_str()).collect(); + Err(anyhow!( + "Module format '{}' not found in package-specs. Available: {}", + format, + available.join(", ") + )) + } + } + None => { + // No format specified - use first package-spec, warn if multiple exist + if package_specs.len() > 1 { + let available: Vec<&str> = package_specs.iter().map(|s| s.module.as_str()).collect(); + eprintln!( + "Warning: Multiple package-specs configured ({}). Using '{}'. \ + Specify --module-format to choose a different one.", + available.join(", "), + package_specs[0].module + ); + } + Ok(package_specs[0].module.clone()) + } + } +} + +/// 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 AST file for a module. +/// +/// The AST file is generated during the parse phase and is needed for compilation. +fn get_ast_path(build_state: &BuildCommandState, module_name: &str) -> 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))?; + + if let SourceType::SourceFile(source_file) = &module.source_type { + let source_path = &source_file.implementation.path; + let ast_file = source_path.with_extension("ast"); + Ok(package.get_build_path().join(ast_file)) + } else { + Err(anyhow!("Cannot get AST path for non-source module")) + } +} diff --git a/rewatch/src/build/compile.rs b/rewatch/src/build/compile.rs index bda22e12d47..092b43b68e1 100644 --- a/rewatch/src/build/compile.rs +++ b/rewatch/src/build/compile.rs @@ -469,6 +469,18 @@ pub fn get_runtime_path_args( ]) } +/// Output mode for compiler arguments. +/// Controls whether bsc writes to files or outputs to stdout. +pub enum OutputMode { + /// Normal mode: write JS to files based on package-specs configuration. + /// Includes -bs-package-name, -bs-package-output, -bs-suffix, -bs-module-system for each spec. + ToFile, + /// Stdout mode: output JS to stdout for one-shot compilation. + /// Only includes -bs-module-system with the specified format. + /// Omits -bs-package-name (which triggers file output) and -bs-package-output. + ToStdout { module_format: String }, +} + pub fn compiler_args( config: &config::Config, ast_path: &Path, @@ -485,6 +497,8 @@ pub fn compiler_args( is_local_dep: bool, // Command-line --warn-error flag override (takes precedence over rescript.json config) warn_error_override: Option, + // Output mode: ToFile for normal compilation, ToStdout for one-shot compilation + output_mode: OutputMode, ) -> Result> { let bsc_flags = config::flatten_flags(&config.compiler_flags); let dependency_paths = get_dependency_paths(config, project_context, packages, is_type_dev); @@ -531,40 +545,52 @@ pub fn compiler_args( false => vec![], }; - let package_name_arg = vec!["-bs-package-name".to_string(), config.name.to_owned()]; - - let implementation_args = if is_interface { - debug!("Compiling interface file: {}", &module_name); - vec![] - } else { - debug!("Compiling file: {}", &module_name); - let specs = root_config.get_package_specs(); + // Package name and implementation args depend on output mode + let (package_name_arg, implementation_args) = match &output_mode { + OutputMode::ToFile => { + let package_name = vec!["-bs-package-name".to_string(), config.name.to_owned()]; + let impl_args = if is_interface { + debug!("Compiling interface file: {}", &module_name); + vec![] + } else { + debug!("Compiling file: {}", &module_name); + let specs = root_config.get_package_specs(); - specs - .iter() - .flat_map(|spec| { - vec![ - "-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), - ), - ] - }) - .collect() + 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(), + 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() + }; + (package_name, impl_args) + } + OutputMode::ToStdout { module_format } => { + // For stdout mode: no -bs-package-name (triggers file output), + // only -bs-module-system with the chosen format + debug!("Compiling file to stdout: {}", &module_name); + let impl_args = vec!["-bs-module-system".to_string(), module_format.clone()]; + (vec![], impl_args) + } }; let runtime_path_args = get_runtime_path_args(config, project_context)?; @@ -602,6 +628,81 @@ pub fn compiler_args( .concat()) } +/// Compile a single file and return its JavaScript output on stdout. +/// This is used by `compile_one` for the target file only. +pub fn compile_file_to_stdout( + package: &packages::Package, + ast_path: &Path, + module: &Module, + build_state: &BuildState, + warn_error_override: Option, + module_format: &str, +) -> Result { + let BuildState { + packages, + project_context, + compiler_info, + .. + } = build_state; + + let build_path_abs = package.get_build_path(); + let implementation_file_path = match &module.source_type { + SourceType::SourceFile(source_file) => Ok(&source_file.implementation.path), + sourcetype => Err(format!( + "Tried to compile a file that is not a source file ({}). Path to AST: {}. ", + sourcetype, + ast_path.to_string_lossy() + )), + } + .map_err(|e| anyhow!(e))?; + + let has_interface = module.get_interface().is_some(); + let is_type_dev = module.is_type_dev; + + let args = compiler_args( + &package.config, + ast_path, + implementation_file_path, + false, // not interface - we want implementation output + has_interface, + project_context, + &Some(packages), + is_type_dev, + package.is_local_dep, + warn_error_override, + OutputMode::ToStdout { + module_format: module_format.to_string(), + }, + )?; + + let output = Command::new(&compiler_info.bsc_path) + .current_dir( + build_path_abs + .canonicalize() + .map(StrippedVerbatimPath::to_stripped_verbatim_path) + .ok() + .unwrap(), + ) + .args(&args) + .output(); + + match output { + Ok(x) if !x.status.success() => { + let stderr = String::from_utf8_lossy(&x.stderr); + let stdout = String::from_utf8_lossy(&x.stdout); + Err(anyhow!(stderr.to_string() + &stdout)) + } + Err(e) => Err(anyhow!( + "Could not compile file. Error: {e}. Path to AST: {ast_path:?}" + )), + Ok(x) => { + // JavaScript output is on stdout + let js_output = String::from_utf8_lossy(&x.stdout).to_string(); + Ok(js_output) + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum DependentPackage { Normal(String), @@ -729,6 +830,7 @@ fn compile_file( is_type_dev, package.is_local_dep, warn_error_override, + OutputMode::ToFile, )?; let to_mjs = Command::new(&compiler_info.bsc_path) diff --git a/rewatch/src/cli.rs b/rewatch/src/cli.rs index c59fd3918bb..3b3ae053d48 100644 --- a/rewatch/src/cli.rs +++ b/rewatch/src/cli.rs @@ -461,6 +461,19 @@ 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, + + /// Module format to use (commonjs or esmodule). If not specified and multiple + /// package-specs are configured, the first one is used with a warning. + #[arg(long)] + module_format: Option, + + #[command(flatten)] + warn_error: WarnErrorArg, + }, } impl Deref for FolderArg { diff --git a/rewatch/src/main.rs b/rewatch/src/main.rs index 65b1ff5b648..c1fc4293c5e 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()); @@ -50,6 +50,38 @@ fn main() -> Result<()> { println!("{}", build::get_compiler_args(Path::new(&path))?); std::process::exit(0); } + cli::Command::CompileFile { + path, + module_format, + 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(), + module_format, + ) { + 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 00000000000..4e9c5629122 --- /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 b484df0ee0e..3847d1d8cdf 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 diff --git a/tests/build_tests/cli_help/input.js b/tests/build_tests/cli_help/input.js index 8c7828e34ec..c65cde4a04a 100755 --- a/tests/build_tests/cli_help/input.js +++ b/tests/build_tests/cli_help/input.js @@ -18,6 +18,7 @@ const cliHelp = " clean Clean the build artifacts\n" + " format Format ReScript files\n" + " compiler-args Print the compiler arguments for a ReScript source file\n" + + " compile-file Compile a single file and output JavaScript to stdout\n" + " help Print this message or the help of the given subcommand(s)\n" + "\n" + "Options:\n" + @@ -88,6 +89,21 @@ const compilerArgsHelp = " -q, --quiet... Decrease logging verbosity\n" + " -h, --help Print help\n"; +const compileFileHelp = + "Compile a single file and output JavaScript to stdout\n" + + "\n" + + "Usage: rescript compile-file [OPTIONS] \n" + + "\n" + + "Arguments:\n" + + " Path to a ReScript source file (.res or .resi)\n" + + "\n" + + "Options:\n" + + " --module-format Module format to use (commonjs or esmodule). If not specified and multiple package-specs are configured, the first one is used with a warning\n" + + " -v, --verbose... Increase logging verbosity\n" + + " -q, --quiet... Decrease logging verbosity\n" + + ' --warn-error Override warning configuration from rescript.json. Example: --warn-error "+3+8+11+12+26+27+31+32+33+34+35+39+44+45+110"\n' + + " -h, --help Print help\n"; + /** * @param {string[]} params * @param {{ stdout: string; stderr: string; status: number; }} expected @@ -216,3 +232,17 @@ await test(["compiler-args", "-h"], { stderr: "", status: 0, }); + +// Shows compile-file help with --help arg +await test(["compile-file", "--help"], { + stdout: compileFileHelp, + stderr: "", + status: 0, +}); + +// Shows compile-file help with -h arg +await test(["compile-file", "-h"], { + stdout: compileFileHelp, + stderr: "", + status: 0, +});