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 (see harn#2313 / W13).
3//!
4//! The .harn port owns argv parsing, walking, --out path mirroring, and
5//! the per-file progress + summary render. The actual parse + typecheck
6//! + compile work stays on the legacy Rust path: the script spawns
7//!   `harn precompile <single-file>` per source with `HARN_CLI_IMPL=rust`
8//!   so the child resolves to [`run_legacy`] instead of recursing back
9//!   into the wedge.
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`).
20//!
21//! `HARN_CLI_IMPL=rust` keeps the legacy Rust impl reachable for the
22//! parity-snapshot harness (#2299) and the C1 LOC ratchet (#2314)
23//! until the .harn impl is the default everywhere.
24
25use std::path::{Path, PathBuf};
26
27use harn_parser::DiagnosticSeverity;
28use harn_vm::module_artifact::ModuleArtifact;
29
30use crate::cli::PrecompileArgs;
31use crate::command_error;
32use crate::commands::collect_harn_files;
33use crate::dispatch;
34use crate::env_guard::ScopedEnvVar;
35use crate::parse_source_file;
36
37/// Env var the embedded `cli/precompile` script reads to find the
38/// running `harn` binary path. Set from `std::env::current_exe()` so
39/// the child invocation is robust to $PATH ordering / test sandboxes.
40pub const PRECOMPILE_BIN_ENV: &str = "HARN_CLI_SELF_EXE";
41
42/// Output directory the script forwards to its per-file child via
43/// `--out`. Cleared on drop so a follow-on invocation in the same
44/// process sees a clean env.
45const PRECOMPILE_OUT_ENV: &str = "HARN_PRECOMPILE_OUT";
46const PRECOMPILE_KEEP_GOING_ENV: &str = "HARN_PRECOMPILE_KEEP_GOING";
47const PRECOMPILE_QUIET_ENV: &str = "HARN_PRECOMPILE_QUIET";
48
49pub async fn run(args: PrecompileArgs) {
50    if std::env::var("HARN_CLI_IMPL").as_deref() == Ok("rust") {
51        run_legacy(args);
52        return;
53    }
54
55    let exe = std::env::current_exe().unwrap_or_else(|error| {
56        command_error(&format!("failed to resolve current executable: {error}"))
57    });
58    let exe_str = exe.to_string_lossy().into_owned();
59    let _bin = ScopedEnvVar::set(PRECOMPILE_BIN_ENV, &exe_str);
60    let _out = args
61        .out
62        .as_ref()
63        .map(|p| ScopedEnvVar::set(PRECOMPILE_OUT_ENV, &p.to_string_lossy()));
64    let _keep = if args.keep_going {
65        Some(ScopedEnvVar::set(PRECOMPILE_KEEP_GOING_ENV, "1"))
66    } else {
67        None
68    };
69    let _quiet = if args.quiet {
70        Some(ScopedEnvVar::set(PRECOMPILE_QUIET_ENV, "1"))
71    } else {
72        None
73    };
74
75    let argv = vec![args.target.to_string_lossy().into_owned()];
76    // Use the no-sandbox dispatch: precompile's target is whatever path
77    // the user passed, which is typically outside the script's
78    // tempfile-derived workspace root. The actual compile work still
79    // runs inside the spawned child's default sandbox; the orchestration
80    // layer this script implements just needs to read directory entries.
81    let exit = dispatch::dispatch_to_embedded_script_no_sandbox(
82        "precompile",
83        argv,
84        /* json_mode */ false,
85    )
86    .await;
87    if exit != 0 {
88        std::process::exit(exit);
89    }
90}
91
92/// Outcome aggregated across all sources walked in one invocation.
93#[derive(Default)]
94struct Stats {
95    compiled: usize,
96    failed: usize,
97}
98
99/// One file can be both an executable entry pipeline AND an imported
100/// module. Precompile emits both so the runtime loader hits whichever
101/// path the user takes.
102struct PrecompileArtifacts {
103    entry_chunk: harn_vm::Chunk,
104    module_artifact: Option<ModuleArtifact>,
105}
106
107/// Legacy Rust impl, kept behind `HARN_CLI_IMPL=rust` for the
108/// parity-snapshot harness and as the inner compiler the .harn port
109/// dispatches each per-file child to. The C1 ratchet (#2314) removes
110/// this once the .harn impl is the production default everywhere.
111pub fn run_legacy(args: PrecompileArgs) {
112    let target = args.target.clone();
113    if !target.exists() {
114        command_error(&format!("target does not exist: {}", target.display()));
115    }
116
117    let (sources, source_root) = if target.is_dir() {
118        let mut files = Vec::new();
119        collect_harn_files(&target, &mut files);
120        files.sort();
121        files.dedup();
122        let root = target.canonicalize().unwrap_or_else(|_| target.clone());
123        (files, Some(root))
124    } else {
125        (vec![target.clone()], None)
126    };
127
128    if sources.is_empty() {
129        command_error(&format!("no .harn files found under {}", target.display()));
130    }
131
132    let mut stats = Stats::default();
133    for source in &sources {
134        let result = precompile_one(source, source_root.as_deref(), args.out.as_deref());
135        match result {
136            Ok(out_path) => {
137                stats.compiled += 1;
138                if !args.quiet {
139                    println!("{} -> {}", source.display(), out_path.display());
140                }
141            }
142            Err(err) => {
143                stats.failed += 1;
144                eprintln!("{}: {err}", source.display());
145                if !args.keep_going {
146                    break;
147                }
148            }
149        }
150    }
151
152    if !args.quiet {
153        eprintln!(
154            "precompile: {} succeeded, {} failed",
155            stats.compiled, stats.failed
156        );
157    }
158    if stats.failed > 0 {
159        std::process::exit(1);
160    }
161}
162
163fn precompile_one(
164    source_path: &Path,
165    source_root: Option<&Path>,
166    out_root: Option<&Path>,
167) -> Result<PathBuf, String> {
168    let source = std::fs::read_to_string(source_path).map_err(|e| format!("read: {e}"))?;
169    let path_str = source_path.to_string_lossy();
170
171    let (parsed_source, program) = parse_source_file(&path_str);
172    debug_assert_eq!(parsed_source, source);
173
174    let mut had_type_error = false;
175    let mut messages = String::new();
176    for diag in harn_parser::TypeChecker::new().check_with_source(&program, &source) {
177        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, &path_str, &diag);
178        if matches!(diag.severity, DiagnosticSeverity::Error) {
179            had_type_error = true;
180        }
181        messages.push_str(&rendered);
182    }
183    if had_type_error {
184        return Err(format!("type errors:\n{messages}"));
185    }
186    if !messages.is_empty() {
187        eprint!("{messages}");
188    }
189
190    let artifacts = compile_artifacts(source_path, &program)?;
191    let key = harn_vm::bytecode_cache::CacheKey::from_source(source_path, &source);
192
193    let entry_dest = output_path(
194        source_path,
195        source_root,
196        out_root,
197        harn_vm::bytecode_cache::CACHE_EXTENSION,
198    )?;
199    harn_vm::bytecode_cache::store_at(&entry_dest, &key, &artifacts.entry_chunk)
200        .map_err(|e| format!("write {}: {e}", entry_dest.display()))?;
201
202    if let Some(module_artifact) = &artifacts.module_artifact {
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, &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    program: &[harn_parser::SNode],
225) -> Result<PrecompileArtifacts, String> {
226    let entry_chunk = harn_vm::Compiler::new()
227        .compile(program)
228        .map_err(|e| format!("compile error: {e}"))?;
229    let module_artifact = harn_vm::module_artifact::compile_module_artifact(
230        program,
231        Some(source_path.display().to_string()),
232    )
233    .map_err(|e| format!("module compile error: {e}"))
234    .ok();
235    Ok(PrecompileArtifacts {
236        entry_chunk,
237        module_artifact,
238    })
239}
240
241/// Map a source path under (optional) `source_root` to its destination
242/// under (optional) `out_root` with the given file extension. When no
243/// `out_root` is given the artifact lands adjacent to the source.
244fn output_path(
245    source_path: &Path,
246    source_root: Option<&Path>,
247    out_root: Option<&Path>,
248    extension: &str,
249) -> Result<PathBuf, String> {
250    let stem = source_path
251        .file_stem()
252        .ok_or_else(|| format!("source has no file stem: {}", source_path.display()))?;
253    let Some(out_root) = out_root else {
254        let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
255        let mut adjacent = parent.join(stem);
256        adjacent.set_extension(extension);
257        return Ok(adjacent);
258    };
259    let relative = match source_root {
260        Some(root) => {
261            let canonical = source_path
262                .canonicalize()
263                .unwrap_or_else(|_| source_path.to_path_buf());
264            canonical
265                .strip_prefix(root)
266                .map(Path::to_path_buf)
267                .unwrap_or_else(|_| {
268                    PathBuf::from(source_path.file_name().unwrap_or(source_path.as_os_str()))
269                })
270        }
271        None => PathBuf::from(
272            source_path
273                .file_name()
274                .ok_or_else(|| format!("source has no file name: {}", source_path.display()))?,
275        ),
276    };
277    let mut dest = out_root.join(&relative);
278    dest.set_extension(extension);
279    Ok(dest)
280}