Skip to content

Commit a6b393a

Browse files
committed
Proof of concept: one shot file compilation
1 parent 34e1219 commit a6b393a

File tree

5 files changed

+293
-2
lines changed

5 files changed

+293
-2
lines changed

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

rewatch/src/cli.rs

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

467475
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());
@@ -37,6 +37,28 @@ fn main() -> Result<()> {
3737
println!("{}", build::get_compiler_args(Path::new(&path))?);
3838
std::process::exit(0);
3939
}
40+
cli::Command::CompileFile { path, warn_error } => {
41+
// Find project root by walking up from file path (same as CompilerArgs command)
42+
let file_path = Path::new(&path);
43+
let project_root = helpers::get_abs_path(
44+
&helpers::get_nearest_config(file_path).expect("Couldn't find package root (rescript.json)"),
45+
);
46+
47+
let _lock = get_lock(project_root.to_str().unwrap());
48+
49+
match build::compile_one(file_path, &project_root, plain_output, (*warn_error).clone()) {
50+
Ok(js_output) => {
51+
// Output JS to stdout (clean for piping)
52+
print!("{js_output}");
53+
std::process::exit(0)
54+
}
55+
Err(e) => {
56+
// Errors go to stderr
57+
eprintln!("{e}");
58+
std::process::exit(1)
59+
}
60+
}
61+
}
4062
cli::Command::Build(build_args) => {
4163
let _lock = get_lock(&build_args.folder);
4264

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)