nova_driver/lib.rs
1//! Compilation pipeline orchestration for Nova.
2//!
3//! Drives source → lex → parse → resolve → typecheck → MIR →
4//! Cranelift (JIT for `nova run`, object emission + native linking for
5//! `nova build`), rendering diagnostics from every stage through
6//! `nova-diagnostics`. This is the crate behind `nova run`,
7//! `nova build`, and `nova check`.
8
9mod analyze;
10mod link;
11mod program;
12mod runtime_cache;
13
14pub use analyze::{analyze, analyze_program, Analysis, DiskSources, Options, Probe, Sources};
15pub use program::{package_of, Program, Root, RootKind, Roots};
16pub use runtime_cache::{set_embedded_runtime, EmbeddedRuntime};
17
18use std::collections::HashSet;
19use std::hash::{Hash, Hasher};
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result};
23use nova_codegen_cranelift::CompiledProgram;
24use nova_diagnostics::{render, Diagnostic, FileDb, FileId, Severity, Span};
25use nova_hir as hir;
26use nova_hir::Ty;
27use nova_pm::PackageId;
28use nova_resolver::{Builtin, Def, DefId, DefKind, Definitions, ModuleId, ModuleSource, TestFn};
29use program::Loaded;
30
31/// Outcome of a pipeline invocation that may fail with user errors.
32pub enum Outcome<T> {
33 /// The pipeline succeeded.
34 Ok(T),
35 /// User-facing errors were reported (already rendered to stderr).
36 Failed {
37 /// Number of error-severity diagnostics.
38 errors: usize,
39 },
40}
41
42/// Type-check a file, printing diagnostics. `Outcome::Ok(())` means the
43/// program is well-formed and would compile.
44///
45/// This also runs monomorphization (MIR lowering), since some checks —
46/// notably trait-bound satisfaction — are performed there per
47/// `12-TYPESYSTEM.md` §5.4. Without it `nova check` would call a program
48/// "well-formed" that `nova run` / `nova build` then reject.
49pub fn check_file(path: &Path) -> Result<Outcome<()>> {
50 check_program(Program::for_file(path))
51}
52
53/// What [`check_program_counted`] rendered.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Checked {
56 pub errors: usize,
57 /// The warnings shown. A registry package's are not (spec 3.3b §5.4).
58 pub warnings: usize,
59}
60
61/// [`check_file`] on `program` (spec 3.3a §5.1). MIR runs only when the
62/// program runs: its entry is `src/main.nova`, or a loose file.
63pub fn check_program(program: Program) -> Result<Outcome<()>> {
64 let checked = check_program_counted(program)?;
65 if checked.errors > 0 {
66 Ok(Outcome::Failed {
67 errors: checked.errors,
68 })
69 } else {
70 Ok(Outcome::Ok(()))
71 }
72}
73
74/// [`check_program`], counting the errors and warnings it rendered.
75/// Publishing's verification fails on either (spec 3.3b §6.6).
76pub fn check_program_counted(program: Program) -> Result<Checked> {
77 let runs = program.runs;
78 let mut ctx = FrontendContext::new(program)?;
79 if let Some((module, _tests, _fresh_def_id)) = ctx.check(false)? {
80 if runs {
81 if let Err(diags) = nova_mir::lower_module(&module) {
82 ctx.render(&diags);
83 }
84 }
85 }
86 Ok(Checked {
87 errors: ctx.errors,
88 warnings: ctx.warnings,
89 })
90}
91
92/// Compile a file to native code via the Cranelift JIT.
93pub fn compile_file(path: &Path) -> Result<Outcome<CompiledProgram>> {
94 compile_program(Program::for_file(path))
95}
96
97/// [`compile_file`] on `program`.
98pub fn compile_program(program: Program) -> Result<Outcome<CompiledProgram>> {
99 let mir = match lower_to_mir(program)? {
100 Outcome::Ok(mir) => mir,
101 Outcome::Failed { errors } => return Ok(Outcome::Failed { errors }),
102 };
103 match nova_codegen_cranelift::compile_jit(&mir) {
104 Ok(program) => Ok(Outcome::Ok(program)),
105 Err(e) => {
106 // An unresolvable `extern` symbol is a user error (the symbol isn't
107 // in the C runtime / any loaded library), not a compiler bug — the
108 // JIT can only discover it at finalize time. Report it as a clean
109 // diagnostic, mirroring the `nova build` linker-error path.
110 if let Some(sym) = e.downcast_ref::<nova_codegen_cranelift::UnresolvedExternSymbol>() {
111 let diag = Diagnostic::error(
112 "E0902",
113 format!(
114 "{sym} at run time; an `extern` function must be provided by the \
115 C runtime or an already-loaded library (a `nova build` executable \
116 resolves it at link time instead)"
117 ),
118 );
119 render::emit_all(&FileDb::new(), &[diag]);
120 return Ok(Outcome::Failed { errors: 1 });
121 }
122 Err(e).context("internal codegen error (this is a compiler bug)")
123 }
124 }
125}
126
127/// Compile a file to a standalone native executable (`nova build`).
128///
129/// Emits a Cranelift object file next to `output`, links it with the
130/// `nova-runtime` static library through the platform linker, and removes
131/// the intermediate object on success.
132pub fn build_file(path: &Path, output: &Path) -> Result<Outcome<PathBuf>> {
133 build_program(Program::for_file(path), output)
134}
135
136/// [`build_file`] on `program`.
137pub fn build_program(program: Program, output: &Path) -> Result<Outcome<PathBuf>> {
138 let mir = match lower_to_mir(program)? {
139 Outcome::Ok(mir) => mir,
140 Outcome::Failed { errors } => return Ok(Outcome::Failed { errors }),
141 };
142 let bytes = nova_codegen_cranelift::compile_object(&mir)
143 .context("internal codegen error (this is a compiler bug)")?;
144
145 let obj_ext = if cfg!(windows) { "obj" } else { "o" };
146 let obj_path = intermediate(output, obj_ext);
147 std::fs::write(&obj_path, bytes)
148 .with_context(|| format!("failed to write {}", obj_path.display()))?;
149
150 let linked = link::link_executable(&obj_path, output);
151 let _ = std::fs::remove_file(&obj_path);
152 linked?;
153 Ok(Outcome::Ok(output.to_path_buf()))
154}
155
156/// An intermediate-file path derived from `output` that can never alias it:
157/// the full output file name plus `.nova.<ext>` (so even `-o out.ll` /
158/// `-o out.obj` yield a distinct `out.ll.nova.ll` / `out.obj.nova.obj` and the
159/// final binary is never overwritten or deleted as an intermediate).
160fn intermediate(output: &Path, ext: &str) -> PathBuf {
161 let mut name = output
162 .file_name()
163 .map(|n| n.to_os_string())
164 .unwrap_or_else(|| std::ffi::OsString::from("out"));
165 name.push(".nova.");
166 name.push(ext);
167 output.with_file_name(name)
168}
169
170/// Compile a file to an optimized standalone executable via the LLVM backend
171/// (`nova build --release`).
172///
173/// Emits textual LLVM IR next to `output`, compiles it to an object file with
174/// a discovered LLVM toolchain (`clang`/`llc`, `-O2`), then links it with the
175/// `nova-runtime` static library through the same platform linker as the debug
176/// build. If no LLVM toolchain is found the generated `.ll` is left in place
177/// and a helpful error is returned.
178pub fn build_file_release(path: &Path, output: &Path) -> Result<Outcome<PathBuf>> {
179 build_program_release(Program::for_file(path), output)
180}
181
182/// [`build_file_release`] on `program`.
183pub fn build_program_release(program: Program, output: &Path) -> Result<Outcome<PathBuf>> {
184 let mir = match lower_to_mir(program)? {
185 Outcome::Ok(mir) => mir,
186 Outcome::Failed { errors } => return Ok(Outcome::Failed { errors }),
187 };
188 let ir = nova_codegen_llvm::compile_ir(&mir)
189 .context("internal LLVM IR generation error (this is a compiler bug)")?;
190
191 let ll_path = intermediate(output, "ll");
192 std::fs::write(&ll_path, ir.as_bytes())
193 .with_context(|| format!("failed to write {}", ll_path.display()))?;
194
195 let obj_ext = if cfg!(windows) { "obj" } else { "o" };
196 let obj_path = intermediate(output, obj_ext);
197
198 // Compile IR → object, then link. Keep the `.ll` on failure (for the LLVM
199 // toolchain to be pointed at or for inspection); remove it on success.
200 let result = link::compile_ir_to_object(&ll_path, &obj_path)
201 .and_then(|()| link::link_executable(&obj_path, output));
202 let _ = std::fs::remove_file(&obj_path);
203 match result {
204 Ok(()) => {
205 let _ = std::fs::remove_file(&ll_path);
206 Ok(Outcome::Ok(output.to_path_buf()))
207 }
208 Err(e) => Err(e.context(format!(
209 "release build failed; generated LLVM IR left at {}",
210 ll_path.display()
211 ))),
212 }
213}
214
215/// A name no Nova source can ever produce for a top-level function: the
216/// lexer's identifier grammar is `[a-zA-Z_][a-zA-Z0-9_]*` (`nova-lexer/src/
217/// lib.rs`'s `RawToken::Ident` regex), which cannot lex a `.` inside one
218/// `Ident` token — the same "impossible in surface syntax" property
219/// `nova_mir::mangle` already relies on (its symbols are `name.<def_id>`).
220/// `build_test_binary` uses this to rename a user's own `main`, if the
221/// source declares one, so it cannot be mistaken for the synthesized one.
222const SHADOWED_USER_MAIN_NAME: &str = "main.shadowed_by_nova_test";
223
224/// What every function called `main` outside the entry module is renamed
225/// to (spec
226/// `docs/superpowers/specs/2026-10-08-phase-3-3a-local-packages-design.md`
227/// §4.6): a name the lexer cannot produce, like
228/// [`SHADOWED_USER_MAIN_NAME`].
229const NOT_THE_ENTRY_MAIN: &str = "main.not_the_entry";
230
231/// The test function each doc test module adds (spec
232/// `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md`
233/// §5.2): in a program of doc tests, the only `@test` function collected.
234/// Each module is its own namespace, so each can use this name.
235pub const DOC_TEST_FUNCTION: &str = "nova_doctest";
236
237/// Rename every `main` declared outside the entry module, `ModuleId(0)`,
238/// so that `nova_mir::lower_module`'s search by name finds the entry's own
239/// `main`, or reports E0601 (spec 3.3a §4.6). A call reaches its function
240/// by `DefId`, so the rename breaks no call.
241fn keep_the_entry_main(module: &mut hir::Module, definitions: &Definitions) {
242 for function in &mut module.functions {
243 if function.name != "main" {
244 continue;
245 }
246 let def = definitions.defs().get(function.def_id.0 as usize);
247 if let Some(Def {
248 kind: DefKind::Fn { item_index },
249 ..
250 }) = def
251 {
252 if definitions.module_of(*item_index) != ModuleId(0) {
253 function.name = NOT_THE_ENTRY_MAIN.to_string();
254 }
255 }
256 }
257}
258
259/// Name each test of a `tests/` file `<file stem>::<function>` (spec 3.3a
260/// §4.6). The synthesized `main` prints these names as its inventory, so
261/// `nova test` and the binary agree on them.
262fn name_tests(tests: &mut [TestFn], definitions: &Definitions, modules: &[Loaded]) {
263 for test in tests {
264 let def = definitions.defs().get(test.def_id.0 as usize);
265 let Some(Def {
266 kind: DefKind::Fn { item_index },
267 ..
268 }) = def
269 else {
270 continue;
271 };
272 let module = definitions.module_of(*item_index).0 as usize;
273 if let Some(module) = modules.get(module).filter(|m| m.test_file) {
274 test.name = format!(
275 "{}::{}",
276 FrontendContext::module_name(&module.path),
277 test.name
278 );
279 }
280 }
281}
282
283/// Compile `path` as a test binary (`nova test`, Task 5): `main` is
284/// synthesized to dispatch to exactly one collected `@test` by index
285/// (`Builtin::TestSelector`) rather than to the file's own `main` — a test
286/// file need not define one, since a missing `main` is enforced only at MIR
287/// lowering (`E0601` in `nova_mir::lower_module`), which the synthesized
288/// function supplies before that check ever runs.
289///
290/// If the source *does* declare its own `main`, that function is renamed
291/// (never removed — something else in the module may still call it by
292/// `DefId`) before the synthesized dispatcher is pushed, so the dispatcher
293/// is the only function left named `"main"`. This matters because
294/// `nova-mir/src/mono.rs`'s entry-point search is
295/// `module.functions.iter().find(|f| f.name == "main")` — the *first*
296/// match — while pushing onto `module.functions` puts the dispatcher
297/// *last*: without the rename, a source file with both `@test` functions
298/// and its own `main` would silently run the user's `main` instead of any
299/// test, compiling and linking cleanly with no diagnostic anywhere.
300///
301/// Returns the built executable's path and the collected tests in source
302/// order, so a caller can run the binary once per test with `NOVA_TEST_INDEX`
303/// set without recompiling or re-deriving either the path or the inventory
304/// from the source again.
305pub fn build_test_binary(path: &Path) -> Result<(PathBuf, Vec<TestFn>)> {
306 build_test_program(Program::for_file(path))
307}
308
309/// Rewrites a diagnostic before it is counted and rendered, with the
310/// program's files at hand (spec
311/// `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md` §7):
312/// it may move the diagnostic's labels into other files of the database,
313/// adding them. `false` hides a warning; an error is always shown.
314pub type MapDiagnostic<'a> = dyn Fn(&mut FileDb, &mut Diagnostic) -> bool + 'a;
315
316/// The text of the std module a program's `FileDb` names `name`, such as
317/// `<std/time>`, or `None` for any other name. The build adds std's modules
318/// to the database only after loading, so a [`MapDiagnostic`] that shows a
319/// diagnostic in one, as a std doc test's does (spec 3.5b §7), reads its
320/// text here.
321pub fn std_source(name: &str) -> Option<&'static str> {
322 let short = name.strip_prefix("<std/")?.strip_suffix('>')?;
323 nova_resolver::STD_MODULES
324 .iter()
325 .chain(std::iter::once(&nova_resolver::STD_TEST_MODULE))
326 .find(|(module, _)| module.strip_prefix("$std.") == Some(short))
327 .map(|&(_, text)| text)
328}
329
330/// [`build_test_binary`] on `program`: the test binary of its roots.
331pub fn build_test_program(program: Program) -> Result<(PathBuf, Vec<TestFn>)> {
332 build_test_program_with(program, &DiskSources, None)
333}
334
335/// [`build_test_program`], reading every module through `sources`, and
336/// passing each diagnostic through `map` before it is counted and rendered
337/// (spec 3.5b §7, §11.2). Doc tests are modules no file holds.
338pub fn build_test_program_with(
339 program: Program,
340 sources: &dyn Sources,
341 map: Option<&MapDiagnostic>,
342) -> Result<(PathBuf, Vec<TestFn>)> {
343 let entry = program
344 .roots
345 .first()
346 .map(|root| root.path.clone())
347 .unwrap_or_default();
348 let mut ctx = FrontendContext::with(program, sources, map)?;
349 let Some((mut module, tests, fresh_def_id)) = ctx.check(true)? else {
350 anyhow::bail!(
351 "could not compile due to {} previous error{}",
352 ctx.errors,
353 if ctx.errors == 1 { "" } else { "s" }
354 );
355 };
356 for f in &mut module.functions {
357 if f.name == "main" {
358 f.name = SHADOWED_USER_MAIN_NAME.to_string();
359 }
360 }
361 module
362 .functions
363 .push(synthesize_test_main(fresh_def_id, &tests));
364
365 let mir = match nova_mir::lower_module(&module) {
366 Ok(mir) => mir,
367 Err(diags) => {
368 ctx.render(&diags);
369 anyhow::bail!(
370 "could not compile due to {} previous error{}",
371 ctx.errors,
372 if ctx.errors == 1 { "" } else { "s" }
373 );
374 }
375 };
376 let bytes = nova_codegen_cranelift::compile_object(&mir)
377 .context("internal codegen error (this is a compiler bug)")?;
378
379 // A caller-distinguishing directory, keyed on the *canonicalized* input
380 // path rather than its file stem: fixtures overwhelmingly share the
381 // name `main.nova` (50 occurrences in this workspace's own
382 // `nova-cli/tests/run_tests.rs` alone), so two different source files in
383 // two different directories must not resolve to the same output path —
384 // a real race under `cargo test`'s default parallelism (a locked-file
385 // link error on Windows; a binary overwritten mid-execution on Unix).
386 let dir = std::env::temp_dir()
387 .join("nova-test-bin")
388 .join(path_fingerprint(&entry));
389 std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
390 let output = dir.join(format!(
391 "{}{}",
392 FrontendContext::module_name(&entry),
393 std::env::consts::EXE_SUFFIX
394 ));
395
396 let obj_ext = if cfg!(windows) { "obj" } else { "o" };
397 let obj_path = intermediate(&output, obj_ext);
398 std::fs::write(&obj_path, bytes)
399 .with_context(|| format!("failed to write {}", obj_path.display()))?;
400
401 let linked = link::link_executable(&obj_path, &output);
402 let _ = std::fs::remove_file(&obj_path);
403 linked?;
404 Ok((output, tests))
405}
406
407/// A short, deterministic fingerprint of `path`'s canonicalized form, for
408/// `build_test_binary`'s output directory. Canonicalizing first means two
409/// different relative spellings of the same file collide on purpose, while
410/// two files that merely share a name in different directories do not. A
411/// path no file holds, such as a doc test's (spec 3.5b §11.2), is joined
412/// onto the current directory instead, so two packages' relative
413/// `<doc tests>/doc0.nova` do not share a binary.
414fn path_fingerprint(path: &Path) -> String {
415 let canon = path.canonicalize().unwrap_or_else(|_| {
416 std::env::current_dir()
417 .map(|dir| dir.join(path))
418 .unwrap_or_else(|_| path.to_path_buf())
419 });
420 let mut hasher = std::collections::hash_map::DefaultHasher::new();
421 canon.hash(&mut hasher);
422 format!("{:016x}", hasher.finish())
423}
424
425/// A zero-width span at the dummy file: there is no source location for a
426/// hand-built `hir::Function`, so every node `synthesize_test_main` builds
427/// uses the same sentinel `FileId::DUMMY` documents itself for.
428fn dummy_span() -> Span {
429 Span::point(0, FileId::DUMMY)
430}
431
432fn expr(kind: hir::ExprKind, ty: Ty) -> hir::Expr {
433 hir::Expr {
434 kind,
435 ty,
436 span: dummy_span(),
437 }
438}
439
440/// Build the dispatching `main` that `build_test_binary` adds to the module.
441///
442/// Constructed directly as an `hir::Function` — mirroring the synthetic
443/// `main` `nova-mir/src/mono.rs`'s own unit tests build, field for field —
444/// rather than as generated Nova source. Generated source is how `std`
445/// reaches a program, but it would route through the resolver, which
446/// enforces `pub`, and `@test` functions are deliberately not `pub`;
447/// constructing the HIR directly sidesteps visibility rather than fighting
448/// it. Because `nova-mir/src/mono.rs`'s monomorphizer locates the entry point
449/// by the name `"main"`, not by `def_id`, nothing downstream needs to know
450/// this particular `main` was synthesized rather than parsed.
451///
452/// Body: bind `test_selector()` to local 0 (`sel`), then one
453/// `if sel == i { test_i() }` per collected test in source order, then
454/// (reached only when `sel` matched none of them — including whenever
455/// `sel < 0`, the documented sentinel) print the inventory: a count line,
456/// then one name per line.
457fn synthesize_test_main(main_id: DefId, tests: &[TestFn]) -> hir::Function {
458 let sel = hir::LocalId(0);
459
460 let mut stmts = vec![expr(
461 hir::ExprKind::Let {
462 local: sel,
463 init: Box::new(expr(
464 hir::ExprKind::Call {
465 func: hir::Callee::Builtin(Builtin::TestSelector),
466 type_args: Vec::new(),
467 args: Vec::new(),
468 },
469 Ty::Int,
470 )),
471 },
472 Ty::Unit,
473 )];
474
475 for (i, t) in tests.iter().enumerate() {
476 let cond = expr(
477 hir::ExprKind::Binary {
478 op: hir::BinOp::Eq,
479 lhs: Box::new(expr(hir::ExprKind::Local(sel), Ty::Int)),
480 rhs: Box::new(expr(hir::ExprKind::IntLit(i as i64), Ty::Int)),
481 },
482 Ty::Bool,
483 );
484 let call = expr(
485 hir::ExprKind::Call {
486 func: hir::Callee::Def(t.def_id),
487 type_args: Vec::new(),
488 args: Vec::new(),
489 },
490 // `nova-resolver`'s `test_shape_violations` rejects a `@test`
491 // function that is `async` or returns non-`Unit` (E0084), so every
492 // callee here is an ordinary `Unit`-typed function. Both halves are
493 // load-bearing: the direct call below neither awaits nor polls, so
494 // an `async` callee would be the future-building wrapper and this
495 // `if` would allocate a state object and discard it, running none
496 // of the test's body.
497 Ty::Unit,
498 );
499 stmts.push(expr(
500 hir::ExprKind::If {
501 cond: Box::new(cond),
502 then: Box::new(call),
503 else_: None,
504 },
505 Ty::Unit,
506 ));
507 }
508
509 let println = |s: String| {
510 expr(
511 hir::ExprKind::Call {
512 func: hir::Callee::Builtin(Builtin::Println),
513 type_args: Vec::new(),
514 args: vec![expr(hir::ExprKind::StrLit(s), Ty::String)],
515 },
516 Ty::Unit,
517 )
518 };
519 let mut inventory = vec![println(tests.len().to_string())];
520 inventory.extend(tests.iter().map(|t| println(t.name.clone())));
521 let sel_is_negative = expr(
522 hir::ExprKind::Binary {
523 op: hir::BinOp::Lt,
524 lhs: Box::new(expr(hir::ExprKind::Local(sel), Ty::Int)),
525 rhs: Box::new(expr(hir::ExprKind::IntLit(0), Ty::Int)),
526 },
527 Ty::Bool,
528 );
529 stmts.push(expr(
530 hir::ExprKind::If {
531 cond: Box::new(sel_is_negative),
532 then: Box::new(expr(
533 hir::ExprKind::Block {
534 stmts: inventory,
535 trailing: None,
536 },
537 Ty::Unit,
538 )),
539 else_: None,
540 },
541 Ty::Unit,
542 ));
543
544 hir::Function {
545 def_id: main_id,
546 name: "main".to_string(),
547 generics: 0,
548 bounds: Vec::new(),
549 takes_env: false,
550 capture_count: 0,
551 params: 0,
552 locals: vec![hir::Local {
553 name: "sel".to_string(),
554 ty: Ty::Int,
555 is_mut: false,
556 span: dummy_span(),
557 }],
558 ret_ty: Ty::Unit,
559 // The synthesized `@test` harness entry point is plain generated
560 // dispatch code (a chain of `if sel == i { test_i() }`), never a
561 // user-written `async fn` — so it is never async.
562 is_async: false,
563 body: expr(
564 hir::ExprKind::Block {
565 stmts,
566 trailing: None,
567 },
568 Ty::Unit,
569 ),
570 span: dummy_span(),
571 }
572}
573
574/// Run the front end and MIR lowering, rendering any diagnostics.
575fn lower_to_mir(program: Program) -> Result<Outcome<nova_mir::Module>> {
576 let mut ctx = FrontendContext::new(program)?;
577 let Some((module, _tests, _fresh_def_id)) = ctx.check(false)? else {
578 return Ok(Outcome::Failed { errors: ctx.errors });
579 };
580 match nova_mir::lower_module(&module) {
581 Ok(mir) => Ok(Outcome::Ok(mir)),
582 Err(diags) => {
583 ctx.render(&diags);
584 Ok(Outcome::Failed { errors: ctx.errors })
585 }
586 }
587}
588
589/// Compile and immediately execute a file (`nova run`), with `args` as the
590/// program's arguments: `std/process`'s `args()` returns exactly this list.
591pub fn run_file(path: &Path, args: Vec<String>) -> Result<Outcome<()>> {
592 run_program(Program::for_file(path), args)
593}
594
595/// [`run_file`] on `program`.
596pub fn run_program(program: Program, args: Vec<String>) -> Result<Outcome<()>> {
597 match compile_program(program)? {
598 Outcome::Ok(program) => {
599 program.run_with_args(args);
600 Ok(Outcome::Ok(()))
601 }
602 Outcome::Failed { errors } => Ok(Outcome::Failed { errors }),
603 }
604}
605
606/// Shared front-end state: what to load, the file database, and error
607/// accounting.
608struct FrontendContext<'a> {
609 db: FileDb,
610 graph: Option<nova_pm::Graph>,
611 roots: Vec<Root>,
612 errors: usize,
613 warnings: usize,
614 /// The registry packages' modules, once loaded: a warning only about
615 /// them is not shown.
616 registry_files: HashSet<FileId>,
617 /// Where every module is read (spec 3.5b §11.2).
618 sources: &'a dyn Sources,
619 /// Called on each diagnostic before it is counted and rendered (spec
620 /// 3.5b §7).
621 map: Option<&'a MapDiagnostic<'a>>,
622}
623
624impl<'a> FrontendContext<'a> {
625 /// Take `program`, rendering its graph's diagnostics. When they hold
626 /// no error, the entry must exist: imported modules are read lazily.
627 fn new(program: Program) -> Result<Self> {
628 FrontendContext::with(program, &DiskSources, None)
629 }
630
631 /// [`FrontendContext::new`], reading every module through `sources`
632 /// and passing each diagnostic through `map`.
633 fn with(
634 program: Program,
635 sources: &'a dyn Sources,
636 map: Option<&'a MapDiagnostic<'a>>,
637 ) -> Result<Self> {
638 let Program {
639 db,
640 graph,
641 roots,
642 diagnostics,
643 ..
644 } = program;
645 let mut ctx = FrontendContext {
646 db,
647 graph,
648 roots,
649 errors: 0,
650 warnings: 0,
651 registry_files: HashSet::new(),
652 sources,
653 map,
654 };
655 ctx.render(&diagnostics);
656 if ctx.errors == 0 {
657 let entry = ctx
658 .entry()
659 .context("nothing to compile: the package has no source file for this")?;
660 ctx.sources
661 .read(entry)
662 .with_context(|| format!("failed to open {}", entry.display()))?;
663 }
664 Ok(ctx)
665 }
666
667 /// The first root.
668 fn entry(&self) -> Option<&Path> {
669 self.roots.first().map(|root| root.path.as_path())
670 }
671
672 fn render(&mut self, diagnostics: &[Diagnostic]) {
673 let mut shown: Vec<Diagnostic> = diagnostics
674 .iter()
675 .filter(|d| !program::a_dependencys_warning(d, &self.registry_files))
676 .cloned()
677 .collect();
678 if let Some(map) = self.map {
679 let db = &mut self.db;
680 shown.retain_mut(|d| map(db, d) || d.severity == Severity::Error);
681 }
682 self.errors += shown
683 .iter()
684 .filter(|d| d.severity == Severity::Error)
685 .count();
686 self.warnings += shown
687 .iter()
688 .filter(|d| d.severity == Severity::Warning)
689 .count();
690 if !shown.is_empty() {
691 render::emit_all(&self.db, &shown);
692 }
693 }
694
695 /// The module name for a source path — its file stem.
696 fn module_name(path: &Path) -> String {
697 path.file_stem()
698 .map(|s| s.to_string_lossy().into_owned())
699 .unwrap_or_else(|| "main".to_string())
700 }
701
702 /// Load, lex and parse every module the roots reach, through
703 /// `sources`, rendering the lex, parse and import diagnostics. The
704 /// loading itself is [`program::load_program`], which the language
705 /// server shares.
706 fn load_modules(&mut self) -> Result<Vec<Loaded>> {
707 let entry = self.entry().map(Path::to_path_buf).unwrap_or_default();
708 let load =
709 program::load_program(self.graph.as_ref(), &self.roots, self.sources, &mut self.db)
710 .with_context(|| format!("failed to read {}", entry.display()))?;
711 self.registry_files = program::registry_files(self.graph.as_ref(), &load.modules);
712 self.render(&load.diagnostics);
713 Ok(load.modules)
714 }
715
716 /// Run load → lex → parse → resolve → typecheck across all modules.
717 /// Returns the merged typed module, the `@test` functions collected along
718 /// the way (source order, per `nova_resolver::TestFn`'s doc comment), and
719 /// a `DefId` that no definition and no function of the module holds — or
720 /// `None` if any stage reported errors.
721 ///
722 /// `with_test_module` seeds `std/test` (`assert`/`assert_eq`/`assert_ne`)
723 /// alongside every `STD_MODULES` entry, so it is glob-imported the same way — but
724 /// only when `true`. `build_test_binary` is the only caller that passes
725 /// `true`; `check_file` and `lower_to_mir` (behind `check_file`,
726 /// `compile_file`, `build_file`, `build_file_release`) pass `false`, so
727 /// those names never resolve in an ordinary compile.
728 ///
729 /// The fresh `DefId` is past both kinds of id the module holds.
730 /// `Definitions` documents `defs` as one global counter shared by every
731 /// definition kind (functions, sums, records, traits, methods, externs,
732 /// associated types), allocated by `push_def` in strict `Vec::len()`
733 /// order, so every id resolution handed out is `< defs().len()`. The type
734 /// checker then numbers the functions it lifts for closures and for named
735 /// functions used as values from `defs().len()` on, so the fresh id is
736 /// also past every function's. `build_test_binary` uses it to name a
737 /// synthesized `main` that cannot alias an id a `@test` function or a
738 /// lifted closure already owns.
739 fn check(
740 &mut self,
741 with_test_module: bool,
742 ) -> Result<Option<(nova_hir::Module, Vec<TestFn>, DefId)>> {
743 // The graph's errors were rendered by `new`.
744 if self.errors > 0 {
745 return Ok(None);
746 }
747 let mut modules = self.load_modules()?;
748 if self.errors > 0 {
749 return Ok(None);
750 }
751 // Whole-branch review, finding 1 (Critical): a `@test` function's
752 // body is written assuming `std/test`'s `assert`/`assert_eq`/
753 // `assert_ne` are in scope, which — per the doc comment above — is
754 // true only when `with_test_module` is `true`. Left in the
755 // compilation unit under `nova run`/`build`/`check`, such a body's
756 // calls to them fail to resolve (`E0001`), which means a program
757 // could never both `nova build` and `nova test` cleanly, and `nova
758 // check` (the editor path) reported spurious errors on every test in
759 // the project. Stripping the items here — before any of them reaches
760 // `nova_resolver::resolve_program` — removes the problem instead of
761 // working around it: a stripped function gets no `DefId`, no scope
762 // entry, and its body is never visited by anything downstream, the
763 // same "does not exist in this build" semantics as Rust's
764 // `#[cfg(test)]`. See `strip_test_functions`'s own doc comment for
765 // why this must happen *before* resolution rather than after.
766 //
767 // Phase 3.3a: under `nova test` the root package's tests are
768 // kept and a dependency's stripped (spec
769 // docs/superpowers/specs/2026-10-08-phase-3-3a-local-packages-design.md
770 // §4.6). Phase 3.5b: a program of doc tests keeps only the test
771 // each doc test adds (spec 3.5b §5.2).
772 strip_test_functions(&mut modules, with_test_module);
773 // Register each embedded std module's source in the `FileDb` so a
774 // syntax error inside one (a compiler bug, since they ship with the
775 // compiler) is reported against a real, named file instead of
776 // `FileId::DUMMY`. One id per `nova_resolver::STD_MODULES` entry, in
777 // order — e.g. `$std.core` names `<std/core>`.
778 let std_files: Vec<FileId> = nova_resolver::STD_MODULES
779 .iter()
780 .map(|&(name, src)| {
781 let short = name.strip_prefix("$std.").unwrap_or(name);
782 self.db.add(format!("<std/{short}>"), src)
783 })
784 .collect();
785 // `std/test`, registered the same way but only under `nova test`
786 // (`with_test_module`): one more `FileId` allocated *conditionally*,
787 // which is fine precisely because it rides alongside `STD_MODULES`
788 // rather than being folded into it — nothing computes an index from
789 // `STD_MODULES.len()` expecting it to already include this entry.
790 let extra_std = with_test_module.then(|| {
791 let (name, src) = nova_resolver::STD_TEST_MODULE;
792 let short = name.strip_prefix("$std.").unwrap_or(name);
793 let file_id = self.db.add(format!("<std/{short}>"), src);
794 (nova_resolver::STD_TEST_MODULE, file_id)
795 });
796 let sources: Vec<ModuleSource> = modules
797 .iter()
798 .map(|m| ModuleSource {
799 name: m.name.clone(),
800 file: &m.ast,
801 imports: m.imports.clone(),
802 text: self.db.get_source(m.file),
803 package: m.package.map(|p| p.0),
804 importable: m.importable.clone(),
805 })
806 .collect();
807 let resolved = nova_resolver::resolve_program(&sources, &std_files, extra_std);
808 self.render(&resolved.diagnostics);
809 if self.errors > 0 {
810 return Ok(None);
811 }
812 let mut tests = resolved.tests;
813 name_tests(&mut tests, &resolved.definitions, &modules);
814
815 let checked = nova_typeck::check_with(
816 &resolved.file,
817 &resolved.definitions,
818 &nova_typeck::CheckOptions {
819 sources: Some(&self.db),
820 ..Default::default()
821 },
822 );
823 self.render(&checked.diagnostics);
824 if self.errors > 0 {
825 return Ok(None);
826 }
827 let mut module = checked.module;
828 keep_the_entry_main(&mut module, &resolved.definitions);
829 let fresh_def_id = DefId(
830 module
831 .functions
832 .iter()
833 .map(|f| f.def_id.0 + 1)
834 .fold(resolved.definitions.defs().len() as u32, u32::max),
835 );
836 Ok(Some((module, tests, fresh_def_id)))
837 }
838}
839
840/// Remove top-level `@test` functions from `modules`, in place, before
841/// resolution:
842/// - without `keep_root_tests` (`nova run`, `build` and `check`), every one;
843/// - with it (`nova test`, and the language server's `tests` option), a
844/// dependency's, whose tests never run in its dependent's binary (spec
845/// 3.3a §4.6), keeping the root package's and a loose program's;
846/// - with it, in a program of doc tests (spec
847/// `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md`
848/// §5.2, §6.2), every one but the [`DOC_TEST_FUNCTION`] each doc test
849/// module adds: the library's tests run in the unit tests' binary, and a
850/// `@test` function a block declares is not collected.
851///
852/// What follows was written when this ran only for `nova run`, `build`
853/// and `check`, and every module was the program's own; why a test is
854/// removed before resolution still holds for every one it removes.
855///
856/// A `@test` function is ordinary Nova source with no special compilation
857/// unit of its own — before this fix, `nova_resolver::resolve_program` collected it,
858/// gave it a `DefId`, and `nova_typeck::check` type-checked its body exactly
859/// like any other function, regardless of `with_test_module`. That body is
860/// written assuming `std/test`'s `assert`/`assert_eq`/`assert_ne` are in
861/// scope; they are seeded only under `nova test` (this file's own doc
862/// comment on `check`), so under every other compilation mode those calls
863/// failed to resolve (`E0001`) — a program containing so much as one test
864/// could not be built or checked at all, and `nova check` (the editor path)
865/// reported spurious errors on every test in the project on every keystroke.
866///
867/// **Removed here, before `resolve_program` ever runs**, rather than kept
868/// through resolution and typeck and discarded afterward. Two designs were
869/// available and only one is actually correct:
870///
871/// - *Strip before resolution* (this one): the function never gets a
872/// `DefId`, never enters its module's scope, and its body is never visited
873/// by anything — the same "does not exist in this build" semantics as
874/// Rust's `#[cfg(test)]`. Anything that still tries to call it by name
875/// (unusual, but not forbidden by anything in the language) gets the
876/// ordinary, well-tested "cannot find function" `E0001`, exactly as if the
877/// function had never been written.
878/// - *Strip after resolution* (e.g. filter `nova_typeck::check`'s per-`DefId`
879/// iteration instead): resolution would still assign the function a real
880/// `DefId` and scope entry, and `collect_signatures` runs before any
881/// per-function body pass, so *other*, kept, functions could still
882/// type-check a call to it successfully against a signature that then has
883/// no corresponding `hir::Function` body in the compiled module — a
884/// dangling `DefId` reference that MIR lowering has no defined behavior
885/// for. This is strictly worse than the bug being fixed: today's failure
886/// is a clean compile error; that failure mode would be a compiler panic
887/// or a silently broken binary, and only in the one case where non-test
888/// code happens to reference a test function by name.
889///
890/// Deliberately done in `nova-driver`, not `nova_resolver`, and keyed off
891/// this driver's own `with_test_module` rather than any new parameter on
892/// `nova_resolver::resolve_program`: `nova_resolver::resolve`/`resolve_program`
893/// are also called directly, with no notion of "compiling for `nova test`",
894/// by a large fraction of `nova-resolver`'s and `nova-typeck`'s own unit
895/// tests — including the very tests that pin `E0082`-`E0085` and `TestFn`
896/// collection on `@test` functions (`nova-typeck/src/check.rs`'s
897/// `an_unknown_attribute_is_e0082_and_names_it` and neighbors). Teaching
898/// `resolve_program` to drop `@test` items itself, gated on whether it was
899/// given `extra_std`, would silently empty out every one of those functions'
900/// subject items before validation ever ran, breaking them. Confining the
901/// strip to this driver function leaves `nova_resolver`'s validation and
902/// collection behavior — and every test that exercises it directly —
903/// completely unchanged.
904///
905/// As a consequence, `nova_resolver::validate_test_function`'s attribute-
906/// shape diagnostics (`E0082`-`E0085`) do not fire for a stripped function
907/// — matching `#[cfg(test)]`, where an invalid test signature is likewise
908/// uncaught outside `cargo test`. `nova test` validates the shape of every
909/// `@test` function it keeps.
910fn strip_test_functions(modules: &mut [Loaded], keep_root_tests: bool) {
911 let doc_tests = modules.iter().any(|m| m.doc);
912 for module in modules.iter_mut() {
913 let root = matches!(module.package, None | Some(PackageId(0)));
914 let doc = module.doc;
915 module.ast.items.retain(|item| match &item.value {
916 nova_ast::Item::Function(f) if f.attrs.iter().any(|a| a.name.value == "test") => {
917 keep_root_tests
918 && if doc_tests {
919 doc && f.name.value == DOC_TEST_FUNCTION
920 } else {
921 root
922 }
923 }
924 _ => true,
925 });
926 }
927}
928
929/// What doc tests need of a build (spec
930/// `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md`
931/// §7, §11.2), where only this crate can see it.
932#[cfg(test)]
933mod doc_test_builds {
934 use super::*;
935
936 /// Every path holds an empty program.
937 struct Memory;
938
939 impl Sources for Memory {
940 fn read(&self, _: &Path) -> std::io::Result<String> {
941 Ok("fn main() {}\n".to_string())
942 }
943 }
944
945 #[test]
946 fn a_map_hides_the_warnings_it_rejects_and_never_an_error() {
947 let map = |_: &mut FileDb, _: &mut Diagnostic| false;
948 let program = Program::loose(Path::new("<nova driver: memory>/main.nova"));
949 let mut ctx = FrontendContext::with(program, &Memory, Some(&map)).expect("the entry reads");
950 ctx.render(&[
951 Diagnostic::warning("W0000", "a warning"),
952 Diagnostic::error("E0000", "an error"),
953 ]);
954 assert_eq!((ctx.errors, ctx.warnings), (1, 0));
955 }
956
957 #[test]
958 fn a_path_no_file_holds_is_fingerprinted_from_the_current_directory() {
959 // Two packages' relative `<doc tests>/doc0.nova` must not share a
960 // test binary.
961 let relative = Path::new("<nova driver: no such dir>").join("doc0.nova");
962 let absolute = std::env::current_dir()
963 .expect("a current directory")
964 .join(&relative);
965 assert_eq!(path_fingerprint(&relative), path_fingerprint(&absolute));
966 }
967
968 #[test]
969 fn std_source_gives_a_std_modules_text_by_its_name_in_the_database() {
970 assert_eq!(
971 std_source("<std/core>"),
972 Some(nova_resolver::STD_MODULES[0].1)
973 );
974 assert_eq!(
975 std_source("<std/time>"),
976 Some(nova_resolver::STD_MODULES[10].1)
977 );
978 assert_eq!(
979 std_source("<std/test>"),
980 Some(nova_resolver::STD_TEST_MODULE.1)
981 );
982 assert_eq!(std_source("<std/nope>"), None);
983 assert_eq!(std_source("std/time/lib.nova"), None);
984 assert_eq!(std_source("src/lib.nova"), None);
985 }
986}
987
988/// End-to-end proof that a generated `async fn` actually *runs*: the future
989/// this compiler builds, JIT-compiled, driven by the real `nova-runtime`
990/// executor, with the value read back out of the state object's output slot.
991///
992/// This lives here rather than in `nova-codegen-cranelift` (which owns the
993/// MIR → native seam) or `nova-cli` (which owns `nova run`) because it needs
994/// both halves at once: the whole front end, to get real MIR out of real
995/// source, and an in-process runtime, to poll the resulting future and inspect
996/// the answer. `nova-driver` is the only crate that already has the front end
997/// and the JIT; `nova-runtime` is a dev-dependency for the executor entry
998/// points.
999///
1000/// **Why `main` is synthesized in MIR instead of written in Nova.** Nothing in
1001/// the language can reach `block_on` yet. Not because a future cannot be
1002/// *named* — `Future` is a nameable type, handled by `nova-typeck`'s
1003/// `convert_ty` ahead of its primitive table, so `fn take(f: Future<Int>)`
1004/// type-checks — but because nothing in source can *drive* one: there is no
1005/// poll or await outside an `async fn`, and an `extern` signature accepts only
1006/// `Int`, `Float` and `Bool`, so no foreign function can be handed one either.
1007/// Task 7's `std/task` is what closes that. Until then the only way to
1008/// *execute* a generated poll function is to
1009/// build the calling code at the level where futures do exist — MIR — which is
1010/// exactly the level `nova-cli`'s `nova run` tests cannot reach. So those tests
1011/// assert that such a program compiles and runs; these assert that it computes
1012/// the right value.
1013#[cfg(test)]
1014mod async_end_to_end {
1015 use nova_diagnostics::FileId;
1016 use nova_mir::{Function, MirTy, Module, RtFunc, Stmt, Temp, Terminator};
1017
1018 /// One probe per test, because the executor's task table is a
1019 /// `thread_local!` and libtest gives each `#[test]` its own thread: the
1020 /// probe's own task is therefore always id 0. A second probe on the same
1021 /// thread would shift the ids and make `output_bits(0)` quietly return the
1022 /// first probe's answer, so that is refused rather than reasoned about.
1023 const PROBE_TASK_ID: i64 = 0;
1024
1025 thread_local! {
1026 static PROBED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1027 }
1028
1029 /// An argument to pass to the async function under test.
1030 enum Arg {
1031 Int(i64),
1032 Float(f64),
1033 }
1034
1035 /// Compile `src`, call its `async fn <name>` with `args`, drive the
1036 /// resulting future to completion through the real executor, and return
1037 /// the raw 64 bits of its output slot.
1038 ///
1039 /// Raw bits, not a typed value: `STATE_SLOT_OUTPUT` is one 8-byte slot that
1040 /// the poll function stores through with the output's own machine class, and
1041 /// the executor copies out as an `i64` because it cannot tell a `Float` from
1042 /// an `Int` from a pointer. Comparing against `f64::to_bits` is therefore
1043 /// the strongest available check on a `Float` output — it fails if the store
1044 /// or the load used the wrong class, the wrong offset, or the wrong width.
1045 fn run_async_fn(src: &str, name: &str, args: &[Arg]) -> i64 {
1046 run_async_fn_with(src, name, args, &|_| {})
1047 }
1048
1049 /// As [`run_async_fn`], with `fixup` applied to the lowered module before it
1050 /// is compiled.
1051 ///
1052 /// The hook exists for exactly one thing: substituting a poll function that
1053 /// actually suspends. Nothing compiled from Nova source can, in this phase —
1054 /// every future's poll either completes on its first call or stops at an
1055 /// await, and the awaits bottom out in an `async fn` with none, so the whole
1056 /// chain reports `POLL_READY` on the first poll. The `POLL_PENDING` path
1057 /// through a generated poll function is therefore unreachable from source
1058 /// until a primitive that parks exists, and the only way to execute it now is
1059 /// to hand the *awaiting* function -- real generated code, unmodified -- an
1060 /// inner future whose poll pends.
1061 fn run_async_fn_with(src: &str, name: &str, args: &[Arg], fixup: &dyn Fn(&mut Module)) -> i64 {
1062 assert!(
1063 !PROBED.with(|p| p.replace(true)),
1064 "one probe per test: see PROBE_TASK_ID"
1065 );
1066
1067 let (tokens, lex_errors) = nova_lexer::lex(src, FileId::DUMMY);
1068 assert!(lex_errors.is_empty(), "lex: {lex_errors:?}");
1069 let (ast, parse_errors) = nova_parser::parse(&tokens, FileId::DUMMY);
1070 assert!(parse_errors.is_empty(), "parse: {parse_errors:?}");
1071 let resolved = nova_resolver::resolve(&ast.expect("no AST"));
1072 assert!(
1073 resolved.diagnostics.is_empty(),
1074 "resolve: {:?}",
1075 resolved.diagnostics
1076 );
1077 let checked = nova_typeck::check(&resolved.file, &resolved.definitions);
1078 assert!(
1079 checked.diagnostics.is_empty(),
1080 "typeck: {:?}",
1081 checked.diagnostics
1082 );
1083 let mut mir = nova_mir::lower_module(&checked.module).expect("MIR lowering");
1084
1085 // The wrapper: the half of the transform that kept the original
1086 // mangled symbol. Its `$poll` sibling is deliberately NOT called
1087 // directly -- going through the wrapper is what exercises the state
1088 // allocation, the parameter seeding and the fat-pointer construction.
1089 let prefix = format!("{name}.");
1090 let wrapper = mir
1091 .functions
1092 .iter()
1093 .map(|f| f.name.clone())
1094 .find(|n| n.starts_with(&prefix) && !n.ends_with("$poll"))
1095 .unwrap_or_else(|| {
1096 panic!(
1097 "no wrapper for `{name}`; have {:?}",
1098 mir.functions.iter().map(|f| &f.name).collect::<Vec<_>>()
1099 )
1100 });
1101 assert!(
1102 mir.functions
1103 .iter()
1104 .any(|f| f.name == format!("{wrapper}$poll")),
1105 "the transform must also have emitted `{wrapper}$poll`"
1106 );
1107
1108 fixup(&mut mir);
1109 replace_main(&mut mir, &wrapper, args);
1110 let program = nova_codegen_cranelift::compile_jit(&mir).expect("JIT compile");
1111 program.run();
1112
1113 // A poll function returning a status that is neither POLL_PENDING nor
1114 // POLL_READY does not reach this call at all, and does not present as
1115 // a test failure either. `poll_one` panics naming the value, and that
1116 // panic then has to unwind out through the JIT-compiled `main` frame --
1117 // Cranelift output with no landing pads -- so **the process dies** and
1118 // the harness reports only that the test exited abnormally. Run the one
1119 // test with `-- --nocapture` to see the executor's message, which does
1120 // name the offending status.
1121 //
1122 // A task left queued rather than polled -- the other failure shape --
1123 // is caught by `output_bits` itself: it asserts the task is done
1124 // before handing back its output, so that case panics here with its
1125 // own message rather than silently returning the `output: 0`
1126 // initializer. There is no `nova_rt_task_is_done` pre-check for it any
1127 // more: that entry point now takes a future, not a task id, and this
1128 // harness only ever kept `PROBE_TASK_ID`, the id `TaskSpawn` returned
1129 // to the JIT-compiled `main` -- the future itself was never a value
1130 // this Rust frame held, so there is nothing to pass it.
1131 //
1132 // `output_bits` rather than `nova_rt_task_take_output`: the probe is a
1133 // *spawned* task, and a spawned task's GC root is released at
1134 // completion, so its output can no longer be taken. Every probe in
1135 // this module returns a scalar (`Int`, `Float`, `Bool` or unit),
1136 // which is what `output_bits` is for -- its bits are read, not
1137 // dereferenced.
1138 nova_runtime::task::output_bits(PROBE_TASK_ID)
1139 }
1140
1141 /// Overwrite `main` with MIR that builds the future twice: once spawned as
1142 /// the probe (whose output copy the caller reads with `output_bits`), and
1143 /// once as `block_on`'s root, whose only job is to drain the queue so the
1144 /// probe gets polled. Two separate futures, because one future must not be
1145 /// registered as two tasks sharing a state object.
1146 fn replace_main(mir: &mut Module, wrapper: &str, args: &[Arg]) {
1147 let mut temps: Vec<MirTy> = Vec::new();
1148 let mut stmts: Vec<Stmt> = Vec::new();
1149 fn push(temps: &mut Vec<MirTy>, ty: MirTy) -> Temp {
1150 let t = Temp(temps.len() as u32);
1151 temps.push(ty);
1152 t
1153 }
1154
1155 let mut arg_temps = Vec::new();
1156 for a in args {
1157 match a {
1158 Arg::Int(v) => {
1159 let t = push(&mut temps, MirTy::I64);
1160 stmts.push(Stmt::ConstInt(t, *v));
1161 arg_temps.push(t);
1162 }
1163 Arg::Float(v) => {
1164 let t = push(&mut temps, MirTy::F64);
1165 stmts.push(Stmt::ConstFloat(t, *v));
1166 arg_temps.push(t);
1167 }
1168 }
1169 }
1170
1171 for spawn in [true, false] {
1172 let fut = push(&mut temps, MirTy::Ptr);
1173 stmts.push(Stmt::Call {
1174 dst: Some(fut),
1175 callee: wrapper.to_string(),
1176 args: arg_temps.clone(),
1177 });
1178 let status = push(&mut temps, MirTy::I64);
1179 stmts.push(Stmt::CallRuntime {
1180 dst: Some(status),
1181 func: if spawn {
1182 RtFunc::TaskSpawn
1183 } else {
1184 RtFunc::TaskBlockOn
1185 },
1186 args: vec![fut],
1187 });
1188 }
1189
1190 let main = mir
1191 .functions
1192 .iter_mut()
1193 .find(|f| f.name == "main")
1194 .expect("`main` was lowered");
1195 *main = Function {
1196 name: "main".to_string(),
1197 params: 0,
1198 takes_env: false,
1199 capture_count: 0,
1200 temps,
1201 ret: MirTy::Unit,
1202 is_async: false,
1203 blocks: vec![nova_mir::Block {
1204 stmts,
1205 term: Terminator::Return(None),
1206 }],
1207 };
1208 }
1209
1210 #[test]
1211 fn an_await_free_async_fn_returning_int_runs_and_produces_its_value() {
1212 let out = run_async_fn(
1213 "async fn f() -> Int { 40 + 2 }\nfn main() { let x = f() }",
1214 "f",
1215 &[],
1216 );
1217 assert_eq!(out, 42);
1218 }
1219
1220 /// **The `Float` case, and the one that matters most.** `mir_ty` collapses
1221 /// `Int`, `Char` and every pointer-like type onto one 64-bit integer class,
1222 /// so an `Int` probe passes even if the output is stored, loaded or
1223 /// returned through the wrong one of them. `F64` is the only class that
1224 /// crosses register banks, and the exact type at which this project already
1225 /// shipped a reachable ICE: an `async fn f() -> Float { 1.5 }` merely
1226 /// called from `main` hit a Cranelift verifier error that the `Int` version
1227 /// silently survived.
1228 #[test]
1229 fn an_await_free_async_fn_returning_float_runs_and_produces_its_value() {
1230 let out = run_async_fn(
1231 "async fn f() -> Float { 1.5 }\nfn main() { let x = f() }",
1232 "f",
1233 &[],
1234 );
1235 assert_eq!(
1236 out as u64,
1237 1.5f64.to_bits(),
1238 "the output slot must hold 1.5 as an f64 bit pattern, not an \
1239 integer 1 or a truncation: got {out:#x}, want {:#x}",
1240 1.5f64.to_bits()
1241 );
1242 }
1243
1244 /// **`std/core`'s `mix64` and the runtime's `splitmix64_finalize` compute the
1245 /// same function, and until these two probes nothing asserted it.** The
1246 /// records have said it for some time -- ADR 0005 describes `mix64` as the
1247 /// splitmix64 finalizer written in signed Nova with masked shifts, and the
1248 /// runtime's own doc comment says the constants correspond -- but each side
1249 /// was only ever pinned against *published* splitmix64 vectors, separately.
1250 /// Two implementations can both match a published vector at two points and
1251 /// still disagree elsewhere; nothing compared them to each other.
1252 ///
1253 /// The comparison is possible without inverting anything because the seed is
1254 /// a process-global `OnceLock` in `nova-runtime`, and `nova-driver` takes
1255 /// that crate as a dev-dependency of the *same instance* -- so
1256 /// `nova_rt_int_hash_seed()` here returns the very value the JIT-compiled
1257 /// code XORed in. Whichever side reaches the `OnceLock` first initialises
1258 /// it; order does not matter.
1259 ///
1260 /// One probe per test is the module's rule, so the many-key form has to
1261 /// arrive as a single `Int`: the Nova body XOR-accumulates `(k).hash()` over
1262 /// 128 keys spanning both signs, and the assertion recomputes that fold from
1263 /// `splitmix64_finalize`. A constant or a shift drifting on either side
1264 /// changes the fold with overwhelming probability -- a mask mutation alters
1265 /// roughly half of all inputs, so surviving 128 keys is a `2^-128`-order
1266 /// event rather than a coincidence to worry about.
1267 ///
1268 /// Both signs on purpose. Nova's `>>` is arithmetic and `mix64` masks each
1269 /// shift to emulate a logical one, so a dropped mask is visible only on
1270 /// inputs whose sign bit is set -- and the seed makes the *hashed* value's
1271 /// sign unpredictable from the key's, which is why the range is chosen
1272 /// rather than the mask relied upon.
1273 ///
1274 /// **What these two probes cover that nothing else did, counted rather than
1275 /// reasoned.** Both mutations below were applied and the whole workspace
1276 /// suite run for each, and the failures enumerated:
1277 ///
1278 /// - The runtime's `splitmix64_finalize` first constant, last nibble `9` to
1279 /// `b`: **two failures across the suite, and they are these two probes.**
1280 /// Nothing else noticed. `nova_rt_str_hash` routes through that function,
1281 /// but every test of it asserts a relation -- same key same hash, different
1282 /// keys different hashes -- and a relation survives any bijective change.
1283 /// - Nova's `mix64` first constant, `-4658895280553007687` to
1284 /// `...685`: four failures -- `hash_run`, `hash_build_standalone` and these
1285 /// two. The golden's `splitmix64 canonical x1/x3` line reads `false`
1286 /// `false`, which is the published-vector check doing its job.
1287 ///
1288 /// So Nova-side drift already had a witness and Rust-side drift had none.
1289 /// That asymmetry is the whole reason these exist; the fold's own margin is
1290 /// secondary.
1291 ///
1292 /// `Bool` and `Char` get no probe of their own, deliberately. All three
1293 /// impls call this one `mix64`, differing only in what they feed it, and
1294 /// `tests/runtime/hash.stdout`'s `char/int agree` line already pins that
1295 /// agreement -- so a `Bool` or `Char` probe would cost a full compile and
1296 /// JIT for nothing this fold does not already establish.
1297 #[test]
1298 fn mix64_agrees_with_the_runtime_finalizer_over_keys_of_both_signs() {
1299 let out = run_async_fn(
1300 "async fn fold() -> Int {\n\
1301 \x20 let mut acc = 0\n\
1302 \x20 let mut k = -64\n\
1303 \x20 while k < 64 {\n\
1304 \x20 acc = acc ^ (k).hash()\n\
1305 \x20 k = k + 1\n\
1306 \x20 }\n\
1307 \x20 acc\n\
1308 }\n\
1309 fn main() { let x = fold() }",
1310 "fold",
1311 &[],
1312 );
1313 let seed = nova_runtime::nova_rt_int_hash_seed() as u64;
1314 let want = (-64i64..64).fold(0i64, |acc, k| {
1315 acc ^ nova_runtime::splitmix64_finalize((k as u64) ^ seed) as i64
1316 });
1317 assert_eq!(
1318 out, want,
1319 "Nova's `mix64` and the runtime's `splitmix64_finalize` disagree \
1320 somewhere in the keys -64..63. Both are the splitmix64 finalizer \
1321 and the records say so; one of them has drifted. The single-key \
1322 probe names a specific input."
1323 );
1324 }
1325
1326 /// The same tie at one key, kept for diagnosis rather than for coverage.
1327 /// When the fold above fails it reports two folded totals, which say nothing
1328 /// about *where*; this one fails with a concrete input and two concrete
1329 /// hashes. `-1` is chosen because every one of `mix64`'s three masked shifts
1330 /// sign-extends on it before the seed is applied, so a dropped mask is most
1331 /// likely to show here.
1332 #[test]
1333 fn mix64_agrees_with_the_runtime_finalizer_at_a_single_key() {
1334 let out = run_async_fn(
1335 "async fn one() -> Int { (-1).hash() }\n\
1336 fn main() { let x = one() }",
1337 "one",
1338 &[],
1339 );
1340 let seed = nova_runtime::nova_rt_int_hash_seed() as u64;
1341 let want = nova_runtime::splitmix64_finalize((-1i64 as u64) ^ seed) as i64;
1342 assert_eq!(
1343 out, want,
1344 "`(-1).hash()` in Nova and `splitmix64_finalize(-1 ^ seed)` in the \
1345 runtime must agree: got {out:#x}, want {want:#x}"
1346 );
1347 }
1348
1349 /// Arguments reach the WRAPPER, not `poll` -- so a wrapper that forgets to
1350 /// copy them into their state slots leaves the body reading the allocator's
1351 /// zeroes. At `Float`, which also proves the seeding store and the body's
1352 /// reload agree on the register class, not merely on the offset.
1353 #[test]
1354 fn an_async_fns_parameters_reach_its_body_through_the_state_object() {
1355 let out = run_async_fn(
1356 "async fn add(a: Float, b: Float) -> Float { a + b }\n\
1357 fn main() { let x = add(1.0, 2.0) }",
1358 "add",
1359 &[Arg::Float(1.5), Arg::Float(2.25)],
1360 );
1361 assert_eq!(
1362 out as u64,
1363 3.75f64.to_bits(),
1364 "1.5 + 2.25 must reach the body; a zeroed parameter slot yields \
1365 0.0. Commutative, so `parameter_slots_are_seeded_in_order` \
1366 covers ordering: got {out:#x}"
1367 );
1368 }
1369
1370 /// Ordering, which a commutative operator cannot see: `a - b` distinguishes
1371 /// parameter slot 0 from slot 1.
1372 #[test]
1373 fn parameter_slots_are_seeded_in_order() {
1374 let out = run_async_fn(
1375 "async fn sub(a: Float, b: Float) -> Float { a - b }\n\
1376 fn main() { let x = sub(1.0, 2.0) }",
1377 "sub",
1378 &[Arg::Float(10.0), Arg::Float(2.5)],
1379 );
1380 assert_eq!(
1381 out as u64,
1382 7.5f64.to_bits(),
1383 "10.0 - 2.5 = 7.5; reversed slots would give -7.5: got {out:#x}"
1384 );
1385 }
1386
1387 /// A value carried across block boundaries entirely through state slots --
1388 /// the property Task 6 relies on. A `while` loop's accumulator is written
1389 /// in the body block and read in the header block, so if the spill were
1390 /// incomplete this returns garbage or zero rather than the sum.
1391 #[test]
1392 fn a_loop_accumulator_survives_the_spill_across_blocks() {
1393 let out = run_async_fn(
1394 "async fn total(n: Int) -> Int {\n \
1395 let mut i = 0\n \
1396 let mut t = 0\n \
1397 while i < n { t = t + i\n i = i + 1 }\n \
1398 t\n\
1399 }\n\
1400 fn main() { let x = total(1) }",
1401 "total",
1402 &[Arg::Int(10)],
1403 );
1404 assert_eq!(out, 45, "0+1+..+9 = 45");
1405 }
1406
1407 /// A heap value built INSIDE the poll function, then reduced to a scalar
1408 /// output. The state object holds the interpolated `String`'s pointer in a
1409 /// temp slot while `str_len_chars` is called on it, so this covers three
1410 /// things no other probe here does: `nova_rt_alloc` running from inside a
1411 /// generated poll function, a `Ptr`-class value round-tripping through a
1412 /// state slot, and a runtime call in a poll body.
1413 ///
1414 /// A `String` OUTPUT is not asserted directly, because nothing public in
1415 /// `nova-runtime` decodes a `NovaStr` -- the output slot would only be
1416 /// readable as an opaque non-zero pointer, which a wrong slot could
1417 /// accidentally satisfy. Comparing the string inside Nova and returning the
1418 /// `Bool` checks the value instead, and adds the third distinct output class
1419 /// (`MirTy::I8`, an 8-byte slot holding one byte) to the two above.
1420 #[test]
1421 fn a_heap_value_allocated_inside_the_poll_fn_round_trips_through_a_state_slot() {
1422 let out = run_async_fn(
1423 "async fn f(n: Int) -> Bool { let s = \"ab${n}cd\"
1424 s == \"ab7cd\" }
1425 fn main() { let x = f(1) }",
1426 "f",
1427 &[Arg::Int(7)],
1428 );
1429 assert_eq!(
1430 out & 0xff, 1,
1431 "the interpolated string must equal \"ab7cd\"; a parameter that did not reach the body would build \"ab0cd\" and give 0: got {out:#x}"
1432 );
1433 }
1434
1435 /// Replace `<name>`'s generated poll function with one that reports
1436 /// `POLL_PENDING` the first `pends` calls on a given state object and then
1437 /// completes with `value`.
1438 ///
1439 /// Hand-built MIR obeying the same ABI the transform generates against: it
1440 /// reads the resume tag out of slot `STATE_SLOT_TAG`, advances it, writes its
1441 /// `Float` result to `STATE_SLOT_OUTPUT` and returns a status. Only the
1442 /// *awaited* function is substituted; the function under test is the real
1443 /// generated code, so what the probe exercises is its suspend and its resume.
1444 ///
1445 /// One state object per call to `<name>`, since each call allocates its own,
1446 /// so a stub built with `pends = 1` pends once per await rather than once per
1447 /// program.
1448 fn replace_poll_with_a_pending_stub(mir: &mut Module, name: &str, pends: i64, value: f64) {
1449 let prefix = format!("{name}.");
1450 let poll = mir
1451 .functions
1452 .iter_mut()
1453 .find(|f| f.name.starts_with(&prefix) && f.name.ends_with("$poll"))
1454 .unwrap_or_else(|| panic!("no `$poll` for `{name}`"));
1455
1456 // Temp 0 is the state object (the poll ABI's env), temp 1 is task_ctx.
1457 let mut temps = vec![MirTy::Ptr, MirTy::Ptr];
1458 fn push(temps: &mut Vec<MirTy>, ty: MirTy) -> Temp {
1459 let t = Temp(temps.len() as u32);
1460 temps.push(ty);
1461 t
1462 }
1463 let state = Temp(0);
1464
1465 // Block 0 dispatches on the tag: tag k (k < pends) goes to the k-th
1466 // suspend block, and anything else -- which is tag `pends` -- completes.
1467 let tag = push(&mut temps, MirTy::I64);
1468 let dispatch = nova_mir::Block {
1469 stmts: vec![Stmt::RecordField {
1470 dst: tag,
1471 record: state,
1472 index: nova_mir::STATE_SLOT_TAG,
1473 ty: MirTy::I64,
1474 }],
1475 term: Terminator::Switch {
1476 disc: tag,
1477 arms: (0..pends)
1478 .map(|k| (k, nova_mir::BlockId(k as u32 + 1)))
1479 .collect(),
1480 default: nova_mir::BlockId(pends as u32 + 1),
1481 },
1482 };
1483
1484 let mut blocks = vec![dispatch];
1485 for k in 0..pends {
1486 let next = push(&mut temps, MirTy::I64);
1487 let pending = push(&mut temps, MirTy::I64);
1488 blocks.push(nova_mir::Block {
1489 stmts: vec![
1490 Stmt::ConstInt(next, k + 1),
1491 Stmt::SetField {
1492 record: state,
1493 index: nova_mir::STATE_SLOT_TAG,
1494 value: next,
1495 ty: MirTy::I64,
1496 },
1497 Stmt::ConstInt(pending, nova_mir::POLL_PENDING),
1498 ],
1499 term: Terminator::Return(Some(pending)),
1500 });
1501 }
1502 let out = push(&mut temps, MirTy::F64);
1503 let ready = push(&mut temps, MirTy::I64);
1504 blocks.push(nova_mir::Block {
1505 stmts: vec![
1506 Stmt::ConstFloat(out, value),
1507 Stmt::SetField {
1508 record: state,
1509 index: nova_mir::STATE_SLOT_OUTPUT,
1510 value: out,
1511 ty: MirTy::F64,
1512 },
1513 Stmt::ConstInt(ready, nova_mir::POLL_READY),
1514 ],
1515 term: Terminator::Return(Some(ready)),
1516 });
1517
1518 *poll = Function {
1519 name: poll.name.clone(),
1520 params: 1,
1521 takes_env: true,
1522 capture_count: 0,
1523 temps,
1524 ret: MirTy::I64,
1525 is_async: false,
1526 blocks,
1527 };
1528 }
1529
1530 /// **A real suspend and a real resume.** The awaited future pends twice, so
1531 /// the awaiting function -- ordinary generated code -- returns `POLL_PENDING`
1532 /// to the executor, is re-queued, and comes back into the same await twice
1533 /// before the value arrives.
1534 ///
1535 /// This is the probe for the same-tag rule. A suspend that stored the tag
1536 /// *after* its own would resume past this await: the second poll would
1537 /// dispatch on a tag with no arm and reach the trap block, which aborts the
1538 /// process rather than failing the assertion -- so a run that gets as far as
1539 /// comparing a value has already proved the tag came back to the await.
1540 ///
1541 /// At `Float`, and with `+ 1.0` after the await, so the awaited value has to
1542 /// have travelled through the inner state object's output slot as an f64 and
1543 /// then been used: reading the slot before the inner future wrote it yields
1544 /// `0.0`, giving `1.0` rather than `3.5`.
1545 #[test]
1546 fn an_await_on_a_twice_pending_future_resumes_at_the_same_await() {
1547 let out = run_async_fn_with(
1548 "async fn g() -> Float { 0.0 }\n\
1549 async fn f() -> Float { g().await + 1.0 }\n\
1550 fn main() { let x = f() }",
1551 "f",
1552 &[],
1553 &|mir| replace_poll_with_a_pending_stub(mir, "g", 2, 2.5),
1554 );
1555 assert_eq!(
1556 out as u64,
1557 3.5f64.to_bits(),
1558 "2.5 + 1.0 must come back from the resumed await; an output slot read \
1559 before the inner future wrote it gives 1.0: got {out:#x}, want {:#x}",
1560 3.5f64.to_bits()
1561 );
1562 }
1563
1564 /// An await **inside a loop**, suspending on every iteration: one suspend
1565 /// point resumed at four separate times, with the loop's own accumulator and
1566 /// counter living in state slots across each suspension.
1567 ///
1568 /// Three things only this shape reaches. The loop's back edge has to follow
1569 /// the renumbering the split introduces, or it lands in the resumable poll
1570 /// function's trap block on the second iteration. The resume has to re-enter
1571 /// the await rather than the loop header, or the iteration count is wrong. And
1572 /// the accumulator has to survive a *suspend*, not merely a block boundary --
1573 /// the await-free loop probe above already covers the latter, and would pass
1574 /// with the accumulator living in a register across the whole poll call.
1575 ///
1576 /// The result discriminates each: a lost accumulator gives `0.5`, a lost
1577 /// counter loops forever or once, and a wrong awaited value gives `0.0`.
1578 #[test]
1579 fn an_await_inside_a_loop_suspends_and_resumes_on_every_iteration() {
1580 let out = run_async_fn_with(
1581 "async fn half() -> Float { 0.0 }\n\
1582 async fn total(n: Int) -> Float {\n \
1583 let mut i = 0\n \
1584 let mut t = 0.0\n \
1585 while i < n { t = t + half().await\n i = i + 1 }\n \
1586 t\n\
1587 }\n\
1588 fn main() { let x = total(1) }",
1589 "total",
1590 &[Arg::Int(4)],
1591 &|mir| replace_poll_with_a_pending_stub(mir, "half", 1, 0.5),
1592 );
1593 assert_eq!(
1594 out as u64,
1595 2.0f64.to_bits(),
1596 "four iterations of 0.5 must accumulate to 2.0 across four \
1597 suspensions: got {out:#x}, want {:#x}",
1598 2.0f64.to_bits()
1599 );
1600 }
1601
1602 /// The ready path, with no stub anywhere: a suspend point whose inner future
1603 /// completes on its first poll, so the await falls straight through to its
1604 /// continuation. This is what every `.await` a `.nova` file can write does
1605 /// today, and it is a different path through the status switch than the two
1606 /// probes above take.
1607 #[test]
1608 fn an_await_on_an_already_ready_future_falls_through_to_its_continuation() {
1609 let out = run_async_fn(
1610 "async fn g(x: Float) -> Float { x * 2.0 }\n\
1611 async fn f(a: Float) -> Float { g(a).await + 0.25 }\n\
1612 fn main() { let x = f(1.0) }",
1613 "f",
1614 &[Arg::Float(1.5)],
1615 );
1616 assert_eq!(
1617 out as u64,
1618 3.25f64.to_bits(),
1619 "1.5 * 2.0 + 0.25 = 3.25; the awaited value must be the inner \
1620 future's own output, not its argument or zero: got {out:#x}, want {:#x}",
1621 3.25f64.to_bits()
1622 );
1623 }
1624
1625 /// Two awaits in one body, so the resume tags have to be distinct and the
1626 /// second await's continuation has to be reachable from the first's.
1627 /// Subtraction, not addition: reversed continuations give `-1.5`.
1628 #[test]
1629 fn two_awaits_in_one_body_each_get_their_own_resume_state() {
1630 let out = run_async_fn(
1631 "async fn g(x: Float) -> Float { x }\n\
1632 async fn f() -> Float { g(4.0).await - g(2.5).await }\n\
1633 fn main() { let x = f() }",
1634 "f",
1635 &[],
1636 );
1637 assert_eq!(
1638 out as u64,
1639 1.5f64.to_bits(),
1640 "4.0 - 2.5 = 1.5: got {out:#x}, want {:#x}",
1641 1.5f64.to_bits()
1642 );
1643 }
1644
1645 /// An `.await` inside a `match` arm, executed. The suspend point is in a
1646 /// block reached through the match's own tag `Switch`, whose arm targets the
1647 /// split has to renumber -- an arm left naming its pre-split id reaches the
1648 /// resumable poll function's trap block, which aborts the process rather than
1649 /// producing a wrong value.
1650 ///
1651 /// The scrutinee is built inside the `async fn` rather than passed in, since
1652 /// a probe argument can only be a scalar.
1653 #[test]
1654 fn an_await_inside_a_match_arm_runs_the_arm_it_selected() {
1655 let out = run_async_fn(
1656 "type Choice = | Left | Right\n\
1657 async fn g(x: Float) -> Float { x }\n\
1658 async fn f(a: Float) -> Float {\n \
1659 let c: Choice = Left\n \
1660 match c { Left => g(a).await + 1.0, Right => 0.0, }\n\
1661 }\n\
1662 fn main() { let x = f(1.0) }",
1663 "f",
1664 &[Arg::Float(2.5)],
1665 );
1666 assert_eq!(
1667 out as u64,
1668 3.5f64.to_bits(),
1669 "the `Left` arm awaits and adds 1.0, so 2.5 gives 3.5; the `Right` \
1670 arm would give 0.0: got {out:#x}, want {:#x}",
1671 3.5f64.to_bits()
1672 );
1673 }
1674
1675 /// A unit-output await: nothing is copied out of the inner future's output
1676 /// slot, but the inner future still has to be polled and the continuation
1677 /// still has to run. The `Float` the body returns afterwards is what shows it
1678 /// did.
1679 #[test]
1680 fn a_unit_output_await_still_polls_its_future_and_continues() {
1681 let out = run_async_fn(
1682 "async fn g() { }\n\
1683 async fn f(a: Float) -> Float { g().await\n a + 1.0 }\n\
1684 fn main() { let x = f(1.0) }",
1685 "f",
1686 &[Arg::Float(0.5)],
1687 );
1688 assert_eq!(
1689 out as u64,
1690 1.5f64.to_bits(),
1691 "0.5 + 1.0 = 1.5 must run after the unit await: got {out:#x}, want {:#x}",
1692 1.5f64.to_bits()
1693 );
1694 }
1695
1696 /// A unit-returning `async fn` never touches the output slot itself, yet
1697 /// the executor reads it on completion regardless. What is asserted here is
1698 /// that the run completes at all: reaching `output_bits` means the poll
1699 /// function returned exactly `POLL_READY` and the executor's unconditional
1700 /// read of `STATE_SLOT_OUTPUT` stayed inside a state object with the
1701 /// fewest temp slots any body can have -- the `STATE_MIN_SIZE` case.
1702 #[test]
1703 fn a_unit_returning_async_fn_completes_and_reads_a_valid_output_slot() {
1704 let out = run_async_fn("async fn f() { }\nfn main() { let x = f() }", "f", &[]);
1705 assert_eq!(out, 0, "the explicit zero the transform stores for unit");
1706 }
1707}