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