Skip to main content

harn_cli/commands/
precompile.rs

1//! `harn precompile` — dispatches the directory-walk + per-file fanout
2//! to the embedded `cli/precompile.harn` script.
3//!
4//! The .harn port owns argv parsing, walking, --out path mirroring, and
5//! the per-file progress and summary render. The actual parse, typecheck,
6//! and compile work stays in Rust behind a command-specific internal mode:
7//! the script spawns `harn precompile <single-file>` per source with
8//! `HARN_PRECOMPILE_INNER=1` so the child compiles one source instead of
9//! recursing back into the directory walker.
10//!
11//! Phase deferrals: `harn time` and `harn bench` (the other two W13
12//! commands) stay Rust-only in this PR — both depend on in-process VM
13//! thread-locals (LLM trace summary, profile spans, `getrusage` CPU
14//! samples) that don't survive a `spawn_captured` subprocess boundary
15//! without inventing a new child-binary emit protocol. The W13 ticket
16//! description presumed an `--internal-phase-emit` protocol on `harn
17//! run` that doesn't actually exist in the current codebase; the
18//! preconditions for porting each are filed as #2348 (`harn bench` →
19//! `--emit-summary-json`) and #2350 (`harn time` → `--emit-phase-json`).
20use std::path::{Path, PathBuf};
21
22use harn_parser::DiagnosticSeverity;
23use harn_vm::module_artifact::ModuleArtifact;
24
25use crate::cli::PrecompileArgs;
26use crate::command_error;
27use crate::commands::collect_harn_files;
28use crate::dispatch;
29use crate::env_guard::ScopedEnvVar;
30use crate::parse_source_file;
31use crate::typecheck_imports::checker_with_resolved_imports;
32
33/// Env var the embedded `cli/precompile` script reads to find the
34/// running `harn` binary path. Set from `std::env::current_exe()` so
35/// the child invocation is robust to $PATH ordering / test sandboxes.
36pub const PRECOMPILE_BIN_ENV: &str = "HARN_CLI_SELF_EXE";
37
38/// Output directory the script forwards to its per-file child via
39/// `--out`. Cleared on drop so a follow-on invocation in the same
40/// process sees a clean env.
41const PRECOMPILE_OUT_ENV: &str = "HARN_PRECOMPILE_OUT";
42const PRECOMPILE_KEEP_GOING_ENV: &str = "HARN_PRECOMPILE_KEEP_GOING";
43const PRECOMPILE_QUIET_ENV: &str = "HARN_PRECOMPILE_QUIET";
44pub const PRECOMPILE_INNER_ENV: &str = "HARN_PRECOMPILE_INNER";
45
46pub async fn run(args: PrecompileArgs) {
47    if std::env::var(PRECOMPILE_INNER_ENV).as_deref() == Ok("1") {
48        run_inner_compile(args);
49        return;
50    }
51
52    let exe = std::env::current_exe().unwrap_or_else(|error| {
53        command_error(&format!("failed to resolve current executable: {error}"))
54    });
55    let exe_str = exe.to_string_lossy().into_owned();
56    let _bin = ScopedEnvVar::set(PRECOMPILE_BIN_ENV, &exe_str);
57    let _out = args
58        .out
59        .as_ref()
60        .map(|p| ScopedEnvVar::set(PRECOMPILE_OUT_ENV, &p.to_string_lossy()));
61    let _keep = if args.keep_going {
62        Some(ScopedEnvVar::set(PRECOMPILE_KEEP_GOING_ENV, "1"))
63    } else {
64        None
65    };
66    let _quiet = if args.quiet {
67        Some(ScopedEnvVar::set(PRECOMPILE_QUIET_ENV, "1"))
68    } else {
69        None
70    };
71
72    let argv = vec![args.target.to_string_lossy().into_owned()];
73    // Use the no-sandbox dispatch: precompile's target is whatever path
74    // the user passed, which is typically outside the script's
75    // tempfile-derived workspace root. The actual compile work still
76    // runs inside the spawned child's default sandbox; the orchestration
77    // layer this script implements just needs to read directory entries.
78    let exit = dispatch::dispatch_to_embedded_script_no_sandbox(
79        "precompile",
80        argv,
81        /* json_mode */ false,
82    )
83    .await;
84    if exit != 0 {
85        std::process::exit(exit);
86    }
87}
88
89/// Outcome aggregated across all sources walked in one invocation.
90#[derive(Default)]
91struct Stats {
92    compiled: usize,
93    failed: usize,
94}
95
96/// One file can be both an executable entry pipeline AND an imported
97/// module. Precompile emits both so the runtime loader hits whichever
98/// path the user takes.
99struct PrecompileArtifacts {
100    entry_chunk: harn_vm::Chunk,
101    module_artifact: Option<ModuleArtifact>,
102}
103
104/// Rust compiler entrypoint used by the `.harn` directory-walk driver for
105/// each source file.
106pub fn run_inner_compile(args: PrecompileArgs) {
107    let target = args.target.clone();
108    if !target.exists() {
109        command_error(&format!("target does not exist: {}", target.display()));
110    }
111
112    let (sources, source_root) = if target.is_dir() {
113        let mut files = Vec::new();
114        collect_harn_files(&target, &mut files);
115        files.sort();
116        files.dedup();
117        let root = target.canonicalize().unwrap_or_else(|_| target.clone());
118        (files, Some(root))
119    } else {
120        (vec![target.clone()], None)
121    };
122
123    if sources.is_empty() {
124        command_error(&format!("no .harn files found under {}", target.display()));
125    }
126
127    let mut stats = Stats::default();
128    for source in &sources {
129        let result = precompile_one(source, source_root.as_deref(), args.out.as_deref());
130        match result {
131            Ok(out_path) => {
132                stats.compiled += 1;
133                if !args.quiet {
134                    println!("{} -> {}", source.display(), out_path.display());
135                }
136            }
137            Err(err) => {
138                stats.failed += 1;
139                eprintln!("{}: {err}", source.display());
140                if !args.keep_going {
141                    break;
142                }
143            }
144        }
145    }
146
147    if !args.quiet {
148        eprintln!(
149            "precompile: {} succeeded, {} failed",
150            stats.compiled, stats.failed
151        );
152    }
153    if stats.failed > 0 {
154        std::process::exit(1);
155    }
156}
157
158fn precompile_one(
159    source_path: &Path,
160    source_root: Option<&Path>,
161    out_root: Option<&Path>,
162) -> Result<PathBuf, String> {
163    let source = std::fs::read_to_string(source_path).map_err(|e| format!("read: {e}"))?;
164    let path_str = source_path.to_string_lossy();
165
166    let (parsed_source, program) = parse_source_file(&path_str);
167    debug_assert_eq!(parsed_source, source);
168
169    // Resolve imports like `execute`/`harn check` so a call to an imported
170    // symbol that shadows a builtin is checked against the right signature.
171    let checker = checker_with_resolved_imports(harn_parser::TypeChecker::new(), source_path);
172
173    let mut had_type_error = false;
174    let mut messages = String::new();
175    for diag in checker.check_with_source(&program, &source) {
176        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, &path_str, &diag);
177        if matches!(diag.severity, DiagnosticSeverity::Error) {
178            had_type_error = true;
179        }
180        messages.push_str(&rendered);
181    }
182    if had_type_error {
183        return Err(format!("type errors:\n{messages}"));
184    }
185    if !messages.is_empty() {
186        eprint!("{messages}");
187    }
188
189    let artifacts = compile_artifacts(source_path, &source, &program)?;
190    let entry_key = harn_vm::bytecode_cache::CacheKey::from_source(source_path, &source);
191
192    let entry_dest = output_path(
193        source_path,
194        source_root,
195        out_root,
196        harn_vm::bytecode_cache::CACHE_EXTENSION,
197    )?;
198    harn_vm::bytecode_cache::store_at(&entry_dest, &entry_key, &artifacts.entry_chunk)
199        .map_err(|e| format!("write {}: {e}", entry_dest.display()))?;
200
201    if let Some(module_artifact) = &artifacts.module_artifact {
202        let module_key = harn_vm::bytecode_cache::CacheKey::from_module_source(&source);
203        let module_dest = output_path(
204            source_path,
205            source_root,
206            out_root,
207            harn_vm::bytecode_cache::MODULE_CACHE_EXTENSION,
208        )?;
209        harn_vm::bytecode_cache::store_module_at(&module_dest, &module_key, module_artifact)
210            .map_err(|e| format!("write {}: {e}", module_dest.display()))?;
211    }
212
213    Ok(entry_dest)
214}
215
216/// Compile both the entry-chunk view and the module-artifact view of the
217/// same source. A `.harn` file with a `pipeline default { ... }` block is
218/// callable as both an entry and an importable module; one without is
219/// importable but produces an entry chunk that just returns `nil`. We
220/// emit both artifacts unconditionally so the runtime loader hits the
221/// cache regardless of how the user invokes the file.
222fn compile_artifacts(
223    source_path: &Path,
224    source: &str,
225    program: &[harn_parser::SNode],
226) -> Result<PrecompileArtifacts, String> {
227    let imported_enum_candidates = crate::imported_enum_candidates_for_source(source_path, source);
228    let entry_chunk =
229        crate::compiler_with_imported_enum_candidates(imported_enum_candidates.iter().cloned())
230            .compile(program)
231            .map_err(|e| format!("compile error: {e}"))?;
232    let module_artifact =
233        harn_vm::module_artifact::compile_module_artifact_from_source_with_imported_enums(
234            source_path,
235            source,
236            imported_enum_candidates,
237        )
238        .map_err(|e| format!("module compile error: {e}"))
239        .ok();
240    Ok(PrecompileArtifacts {
241        entry_chunk,
242        module_artifact,
243    })
244}
245
246/// Map a source path under (optional) `source_root` to its destination
247/// under (optional) `out_root` with the given file extension. When no
248/// `out_root` is given the artifact lands adjacent to the source.
249fn output_path(
250    source_path: &Path,
251    source_root: Option<&Path>,
252    out_root: Option<&Path>,
253    extension: &str,
254) -> Result<PathBuf, String> {
255    let stem = source_path
256        .file_stem()
257        .ok_or_else(|| format!("source has no file stem: {}", source_path.display()))?;
258    let Some(out_root) = out_root else {
259        let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
260        let mut adjacent = parent.join(stem);
261        adjacent.set_extension(extension);
262        return Ok(adjacent);
263    };
264    let relative = match source_root {
265        Some(root) => {
266            let canonical = source_path
267                .canonicalize()
268                .unwrap_or_else(|_| source_path.to_path_buf());
269            canonical
270                .strip_prefix(root)
271                .map(Path::to_path_buf)
272                .unwrap_or_else(|_| {
273                    PathBuf::from(source_path.file_name().unwrap_or(source_path.as_os_str()))
274                })
275        }
276        None => PathBuf::from(
277            source_path
278                .file_name()
279                .ok_or_else(|| format!("source has no file name: {}", source_path.display()))?,
280        ),
281    };
282    let mut dest = out_root.join(&relative);
283    dest.set_extension(extension);
284    Ok(dest)
285}