Skip to content

Commit b19ae1e

Browse files
committed
Proof of concept: one shot file compilation
1 parent cba7ed7 commit b19ae1e

5 files changed

Lines changed: 293 additions & 2 deletions

File tree

rewatch/src/build.rs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use crate::helpers::emojis::*;
1616
use crate::helpers::{self};
1717
use crate::project_context::ProjectContext;
1818
use crate::{config, sourcedirs};
19+
use ahash::AHashSet;
1920
use anyhow::{Context, Result, anyhow};
2021
use build_types::*;
2122
use console::style;
@@ -548,3 +549,203 @@ pub fn build(
548549
}
549550
}
550551
}
552+
553+
/// Compile a single ReScript file and return its JavaScript output.
554+
///
555+
/// This function performs a targeted one-shot compilation:
556+
/// 1. Initializes build state (reusing cached artifacts from previous builds)
557+
/// 2. Finds the target module from the file path
558+
/// 3. Calculates the dependency closure (all transitive dependencies)
559+
/// 4. Marks target + dependencies as dirty for compilation
560+
/// 5. Runs incremental build to compile only what's needed
561+
/// 6. Reads and returns the generated JavaScript file
562+
///
563+
/// # Workflow
564+
/// Unlike the watch mode which expands UPWARD to dependents when a file changes,
565+
/// this expands DOWNWARD to dependencies to ensure everything needed is compiled.
566+
///
567+
/// # Example
568+
/// If compiling `App.res` which imports `Component.res` which imports `Utils.res`:
569+
/// - Dependency closure: {Utils, Component, App}
570+
/// - Compilation order (via wave algorithm): Utils → Component → App
571+
///
572+
/// # Errors
573+
/// Returns error if:
574+
/// - File doesn't exist or isn't part of the project
575+
/// - Compilation fails (parse errors, type errors, etc.)
576+
/// - Generated JavaScript file cannot be found
577+
pub fn compile_one(
578+
target_file: &Path,
579+
project_root: &Path,
580+
plain_output: bool,
581+
warn_error: Option<String>,
582+
) -> Result<String> {
583+
use std::fs;
584+
585+
// Step 1: Initialize build state
586+
// This leverages any existing .ast/.cmi files from previous builds
587+
let mut build_state = initialize_build(
588+
None,
589+
&None, // no filter
590+
false, // no progress output (keep stderr clean)
591+
project_root,
592+
plain_output,
593+
warn_error,
594+
)?;
595+
596+
// Step 2: Find target module from file path
597+
let target_module_name = find_module_for_file(&build_state, target_file)
598+
.ok_or_else(|| anyhow!("File not found in project: {}", target_file.display()))?;
599+
600+
// Step 3: Mark only the target file as parse_dirty
601+
// This ensures we parse the latest version of the target file
602+
if let Some(module) = build_state.modules.get_mut(&target_module_name) {
603+
if let SourceType::SourceFile(source_file) = &mut module.source_type {
604+
source_file.implementation.parse_dirty = true;
605+
if let Some(interface) = &mut source_file.interface {
606+
interface.parse_dirty = true;
607+
}
608+
}
609+
}
610+
611+
// Step 4: Get dependency closure (downward traversal)
612+
// Unlike compile universe (upward to dependents), we need all dependencies
613+
let dependency_closure = get_dependency_closure(&target_module_name, &build_state);
614+
615+
// Step 5: Mark all dependencies as compile_dirty
616+
for module_name in &dependency_closure {
617+
if let Some(module) = build_state.modules.get_mut(module_name) {
618+
module.compile_dirty = true;
619+
}
620+
}
621+
622+
// Step 6: Run incremental build
623+
// The wave compilation algorithm will compile dependencies first, then the target
624+
incremental_build(
625+
&mut build_state,
626+
None,
627+
false, // not initial build
628+
false, // no progress output
629+
true, // only incremental (no cleanup step)
630+
false, // no sourcedirs
631+
plain_output,
632+
)
633+
.map_err(|e| anyhow!("Compilation failed: {}", e))?;
634+
635+
// Step 7: Find and read the generated JavaScript file
636+
let js_path = get_js_output_path(&build_state, &target_module_name, target_file)?;
637+
let js_content = fs::read_to_string(&js_path)
638+
.map_err(|e| anyhow!("Failed to read generated JS file {}: {}", js_path.display(), e))?;
639+
640+
Ok(js_content)
641+
}
642+
643+
/// Find the module name for a given file path by searching through all modules.
644+
///
645+
/// This performs a linear search through the build state's modules to match
646+
/// the canonical file path. Returns the module name if found.
647+
fn find_module_for_file(build_state: &BuildCommandState, target_file: &Path) -> Option<String> {
648+
let canonical_target = target_file.canonicalize().ok()?;
649+
650+
for (module_name, module) in &build_state.modules {
651+
if let SourceType::SourceFile(source_file) = &module.source_type {
652+
let package = build_state.packages.get(&module.package_name)?;
653+
654+
// Check implementation file
655+
let impl_path = package.path.join(&source_file.implementation.path);
656+
if impl_path.canonicalize().ok().as_ref() == Some(&canonical_target) {
657+
return Some(module_name.clone());
658+
}
659+
660+
// Check interface file if present
661+
if let Some(interface) = &source_file.interface {
662+
let iface_path = package.path.join(&interface.path);
663+
if iface_path.canonicalize().ok().as_ref() == Some(&canonical_target) {
664+
return Some(module_name.clone());
665+
}
666+
}
667+
}
668+
}
669+
670+
None
671+
}
672+
673+
/// Calculate the transitive closure of all dependencies for a given module.
674+
///
675+
/// This performs a downward traversal (dependencies, not dependents):
676+
/// - Module A depends on B and C
677+
/// - B depends on D
678+
/// - Result: {A, B, C, D}
679+
///
680+
/// This is the opposite of the "compile universe" which expands upward to dependents.
681+
fn get_dependency_closure(module_name: &str, build_state: &BuildState) -> AHashSet<String> {
682+
let mut closure = AHashSet::new();
683+
let mut to_process = vec![module_name.to_string()];
684+
685+
while let Some(current) = to_process.pop() {
686+
if !closure.contains(&current) {
687+
closure.insert(current.clone());
688+
689+
if let Some(module) = build_state.get_module(&current) {
690+
// Add all dependencies to process queue
691+
for dep in &module.deps {
692+
if !closure.contains(dep) {
693+
to_process.push(dep.clone());
694+
}
695+
}
696+
}
697+
}
698+
}
699+
700+
closure
701+
}
702+
703+
/// Get the path to the generated JavaScript file for a module.
704+
///
705+
/// Respects the package's configuration for output location and format:
706+
/// - in-source: JS file next to the .res file
707+
/// - out-of-source: JS file in lib/js or lib/es6
708+
/// - Uses first package spec to determine .js vs .mjs extension
709+
fn get_js_output_path(
710+
build_state: &BuildCommandState,
711+
module_name: &str,
712+
_original_file: &Path,
713+
) -> Result<PathBuf> {
714+
let module = build_state
715+
.get_module(module_name)
716+
.ok_or_else(|| anyhow!("Module not found: {}", module_name))?;
717+
718+
let package = build_state
719+
.get_package(&module.package_name)
720+
.ok_or_else(|| anyhow!("Package not found: {}", module.package_name))?;
721+
722+
let root_config = build_state.get_root_config();
723+
let package_specs = root_config.get_package_specs();
724+
let package_spec = package_specs
725+
.first()
726+
.ok_or_else(|| anyhow!("No package specs configured"))?;
727+
728+
let suffix = root_config.get_suffix(package_spec);
729+
730+
if let SourceType::SourceFile(source_file) = &module.source_type {
731+
let source_path = &source_file.implementation.path;
732+
733+
if package_spec.in_source {
734+
// in-source: JS file next to source file
735+
let js_file = source_path.with_extension(&suffix[1..]); // remove leading dot
736+
Ok(package.path.join(js_file))
737+
} else {
738+
// out-of-source: in lib/js or lib/es6
739+
let base_path = if package_spec.is_common_js() {
740+
package.get_js_path()
741+
} else {
742+
package.get_es6_path()
743+
};
744+
745+
let js_file = source_path.with_extension(&suffix[1..]);
746+
Ok(base_path.join(js_file))
747+
}
748+
} else {
749+
Err(anyhow!("Cannot get JS output for non-source module"))
750+
}
751+
}

rewatch/src/cli.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,14 @@ pub enum Command {
461461
#[command()]
462462
path: String,
463463
},
464+
/// Compile a single file and output JavaScript to stdout
465+
CompileFile {
466+
/// Path to a ReScript source file (.res or .resi)
467+
path: String,
468+
469+
#[command(flatten)]
470+
warn_error: WarnErrorArg,
471+
},
464472
}
465473

466474
impl Deref for FolderArg {

rewatch/src/main.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use console::Term;
33
use log::LevelFilter;
44
use std::{io::Write, path::Path};
55

6-
use rescript::{build, cli, cmd, format, lock, watcher};
6+
use rescript::{build, cli, cmd, format, helpers, lock, watcher};
77

88
fn main() -> Result<()> {
99
let cli = cli::parse_with_default().unwrap_or_else(|err| err.exit());
@@ -50,6 +50,28 @@ fn main() -> Result<()> {
5050
println!("{}", build::get_compiler_args(Path::new(&path))?);
5151
std::process::exit(0);
5252
}
53+
cli::Command::CompileFile { path, warn_error } => {
54+
// Find project root by walking up from file path (same as CompilerArgs command)
55+
let file_path = Path::new(&path);
56+
let project_root = helpers::get_abs_path(
57+
&helpers::get_nearest_config(file_path).expect("Couldn't find package root (rescript.json)"),
58+
);
59+
60+
let _lock = get_lock(project_root.to_str().unwrap());
61+
62+
match build::compile_one(file_path, &project_root, plain_output, (*warn_error).clone()) {
63+
Ok(js_output) => {
64+
// Output JS to stdout (clean for piping)
65+
print!("{js_output}");
66+
std::process::exit(0)
67+
}
68+
Err(e) => {
69+
// Errors go to stderr
70+
eprintln!("{e}");
71+
std::process::exit(1)
72+
}
73+
}
74+
}
5375
cli::Command::Build(build_args) => {
5476
let _lock = get_lock(&build_args.folder);
5577

rewatch/tests/compile-one.sh

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/bin/bash
2+
cd $(dirname $0)
3+
source "./utils.sh"
4+
cd ../testrepo
5+
6+
bold "Test: compile-file command should output JS to stdout"
7+
8+
# Build first to ensure artifacts exist
9+
error_output=$(rewatch build 2>&1)
10+
if [ $? -ne 0 ]; then
11+
error "Error building repo"
12+
printf "%s\n" "$error_output" >&2
13+
exit 1
14+
fi
15+
16+
# Test 1: Basic compilation - stdout should contain valid JavaScript
17+
bold "Test: Compile outputs valid JavaScript"
18+
stdout=$(rewatch compile-file packages/main/src/Main.res 2>/dev/null)
19+
if [ $? -ne 0 ]; then
20+
error "Error compiling packages/main/src/Main.res"
21+
exit 1
22+
fi
23+
24+
# Check stdout contains JS (look for common JS patterns)
25+
if echo "$stdout" | grep -q "export\|function\|import" ; then
26+
success "compile outputs JavaScript to stdout"
27+
else
28+
error "compile stdout doesn't look like JavaScript"
29+
echo "$stdout"
30+
exit 1
31+
fi
32+
33+
# Test 2: Compilation from subdirectory should work
34+
bold "Test: Compile works from subdirectory"
35+
pushd packages/main > /dev/null
36+
stdout=$("$REWATCH_EXECUTABLE" compile-file src/Main.res 2>/dev/null)
37+
if [ $? -eq 0 ]; then
38+
success "compile works from subdirectory"
39+
else
40+
error "compile failed from subdirectory"
41+
popd > /dev/null
42+
exit 1
43+
fi
44+
popd > /dev/null
45+
46+
# Test 3: Errors should go to stderr, not stdout
47+
bold "Test: Errors go to stderr, not stdout"
48+
stdout=$(rewatch compile-file packages/main/src/NonExistent.res 2>/dev/null)
49+
stderr=$(rewatch compile-file packages/main/src/NonExistent.res 2>&1 >/dev/null)
50+
if [ -z "$stdout" ] && [ -n "$stderr" ]; then
51+
success "Errors correctly sent to stderr"
52+
else
53+
error "Errors not correctly handled"
54+
echo "stdout: $stdout"
55+
echo "stderr: $stderr"
56+
exit 1
57+
fi
58+
59+
success "All compile-one tests passed"
60+

rewatch/tests/suite.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,4 @@ else
5353
exit 1
5454
fi
5555

56-
./compile.sh && ./watch.sh && ./lock.sh && ./suffix.sh && ./format.sh && ./clean.sh && ./experimental.sh && ./experimental-invalid.sh && ./compiler-args.sh
56+
./compile.sh && ./watch.sh && ./lock.sh && ./suffix.sh && ./format.sh && ./clean.sh && ./experimental.sh && ./experimental-invalid.sh && ./compiler-args.sh && ./compile-one.sh

0 commit comments

Comments
 (0)