struct AsyncHost;
impl ExecutionHost for AsyncHost {
async fn perform(&self, op: AbilityOp) -> Result<AbilityResult, ExecutionHostError> {
match op {
AbilityOp::ResourceOperation(operation) => {
Host.perform(AbilityOp::ResourceOperation(operation)).await
}
AbilityOp::StartProcess(start) => {
let mut record = Record::default();
record.insert("__handle__".to_string(), Value::String("process".into()));
record.insert(
"process".to_string(),
Value::String(start.process_name.into()),
);
record.insert(
"value".to_string(),
start.args.get("value").cloned().unwrap_or(Value::Null),
);
Ok(AbilityResult::Value(Value::Record(Arc::new(record))))
}
AbilityOp::Await(handle) => {
let record = handle
.as_record()
.ok_or_else(|| ExecutionHostError::new("expected handle record"))?;
Ok(AbilityResult::Value(
record.get("value").cloned().unwrap_or(Value::Null),
))
}
AbilityOp::Cancel(_) => Ok(AbilityResult::Value(Value::Null)),
AbilityOp::Print(_) => Ok(AbilityResult::Unit),
AbilityOp::Submit(value) | AbilityOp::Finish(value) | AbilityOp::Fail(value) => {
Ok(AbilityResult::Value(value))
}
_ => Err(ExecutionHostError::new("unsupported host ability")),
}
}
}
#[tokio::test(flavor = "current_thread")]
async fn linked_value_constructor_wraps_host_descriptor() {
let mut resources = crate::LashlangHostCatalog::new();
resources.add_value_constructor(
["timer", "Schedule"],
crate::TypeExpr::Object(vec![crate::TypeField {
name: "expr".into(),
ty: crate::TypeExpr::Str,
optional: false,
}]),
crate::TypeExpr::Ref("timer.Schedule".into()),
);
let surface = crate::LashlangHostEnvironment::new(resources, crate::LashlangAbilities::all());
let program = crate::parse(
r#"
source = timer.Schedule({ expr: "0 8 * * *" })
submit source
"#,
)
.expect("program should parse");
let linked = crate::LinkedModule::link(program, surface).expect("program should link");
let compiled = crate::compile_linked(&linked);
let mut state = State::new();
let outcome = execute_compiled(&compiled, &mut state, &Host)
.await
.expect("program should run");
let ExecutionOutcome::Finished(Value::Record(record)) = outcome else {
panic!("expected host descriptor record, got {outcome:?}");
};
assert_eq!(
record.get(LASH_HOST_DESCRIPTOR_TYPE_KEY),
Some(&Value::String("timer.Schedule".into()))
);
let Some(Value::Record(source)) = record.get(LASH_HOST_DESCRIPTOR_VALUE_KEY) else {
panic!("expected wrapped source record");
};
assert_eq!(source.get("expr"), Some(&Value::String("0 8 * * *".into())));
}
#[tokio::test(flavor = "current_thread")]
async fn process_handles_can_be_started_awaited_and_cancelled() {
let program = crate::parse(
r#"
process echo(value: str) { finish value }
handle = start echo(value: "done")
result = await handle
cancel handle
submit result
"#,
)
.expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &AsyncHost)
.await
.expect("program should run");
let ExecutionOutcome::Finished(value) = outcome else {
panic!("expected finish");
};
let record = value
.as_record()
.expect("await should return wrapped result");
assert_eq!(record["ok"], Value::Bool(true));
assert_eq!(record["value"], Value::String("done".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn start_process_returns_raw_handle_and_passes_explicit_input() {
let host = RecordingProcessHost::default();
let program = crate::parse(
r#"
process scan(root: str) -> str {
finish root
}
handle = start scan(root: ".")
submit handle
"#,
)
.expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &host)
.await
.expect("program should run");
let ExecutionOutcome::Finished(value) = outcome else {
panic!("expected finish");
};
let handle = value.as_record().expect("start should return a handle");
assert_eq!(handle["__handle__"], Value::String("process".into()));
assert_eq!(handle["id"], Value::String("proc-1".into()));
let starts = host.starts.lock().expect("starts lock");
assert_eq!(starts.len(), 1);
let start = &starts[0];
assert_eq!(start.process_name, "scan");
assert_eq!(start.args["root"], Value::String(".".into()));
assert!(start.module_ref.as_str().starts_with("lashlang:v1:sha256:"));
}
#[tokio::test(flavor = "current_thread")]
async fn unlinked_compiled_program_rejects_process_starts() {
let program = crate::parse(
r#"
process scan() { finish 1 }
submit start scan()
"#,
)
.expect("program should parse");
let compiled = compile_program(&program);
let mut state = State::new();
let err = execute_compiled(&compiled, &mut state, &RecordingProcessHost::default())
.await
.expect_err("unlinked start should fail");
assert!(err.to_string().contains("linked lashlang module artifact"));
}
#[test]
fn compiled_process_cache_reuses_process_ref_and_host_requirements_ref() {
let linked = crate::LinkedModule::link(
crate::parse("process scan() { finish 1 }").expect("parse module"),
runtime_test_environment(),
)
.expect("link module");
let process_ref = linked
.artifact
.process_ref("scan")
.expect("scan process ref")
.clone();
let mut cache = CompiledProcessCache::with_capacity(2);
let first = cache
.get_or_compile(
&linked.artifact,
&process_ref,
&linked.host_requirements_ref,
)
.expect("compile first");
let second = cache
.get_or_compile(
&linked.artifact,
&process_ref,
&linked.host_requirements_ref,
)
.expect("compile second");
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(cache.stats().hits, 1);
assert_eq!(cache.stats().misses, 1);
}
#[tokio::test(flavor = "current_thread")]
async fn receiver_module_operation_unwraps_result() {
let value = exec(r#"submit (await tools.echo({ value: "ok" })?)"#)
.await
.expect("module operation should run");
assert_eq!(value, Value::String("ok".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn receiver_module_operation_errors_are_sanitized() {
let err = exec(r#"submit (await tools.err({ value: "nope" })?)"#)
.await
.expect_err("module operation should fail");
assert!(matches!(err, RuntimeError::ValueError { .. }));
assert!(err.to_string().contains("module operation"));
}
#[tokio::test(flavor = "current_thread")]
async fn processess_emit_events_and_terminal_outcomes() {
let host = RecordingProcessHost::default();
let program = Program::block(vec![
Expr::Yield(Box::new(Expr::String("checkpoint".into()))),
Expr::Wake(Box::new(Expr::String("ready".into()))),
Expr::Finish(Some(Box::new(Expr::String("done".into())))),
]);
let mut state = State::new();
let compiled = compile_program(&program);
let outcome = execute_compiled_process(&compiled, &mut state, &host)
.await
.expect("process admins should run");
assert_eq!(
outcome,
ExecutionOutcome::Finished(Value::String("done".into()))
);
let events = host.events.lock().expect("events lock");
assert_eq!(events.len(), 2);
assert_eq!(events[0].kind, ProcessEventKind::Yield);
assert_eq!(events[0].value, Value::String("checkpoint".into()));
assert_eq!(events[1].kind, ProcessEventKind::Wake);
assert_eq!(events[1].value, Value::String("ready".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn while_runs_inside_process_body() {
let program = crate::parse(
r#"
process count_to(limit: int) {
n = 0
while n < limit {
n = n + 1
}
finish n
}
"#,
)
.expect("process with while should parse");
let compiled = crate::compile_process(&program, "count_to").expect("process should compile");
let mut state = State::new();
state
.globals
.insert("limit".to_string(), Value::Number(4.0));
let outcome = execute_compiled_process(&compiled, &mut state, &RecordingProcessHost::default())
.await
.expect("process while should run");
assert_eq!(outcome, ExecutionOutcome::Finished(Value::Number(4.0)));
}
#[tokio::test(flavor = "current_thread")]
async fn value_position_while_leaves_null() {
let program = Program::block(vec![Expr::Submit(Some(Box::new(Expr::While {
condition: Box::new(Expr::Bool(false)),
body: Box::new(Expr::Block(Vec::new())),
})))]);
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &Host)
.await
.expect("value-position while should run");
assert_eq!(outcome, ExecutionOutcome::Finished(Value::Null));
}
#[tokio::test(flavor = "current_thread")]
async fn process_lifecycle_controls_sleep_wait_and_signal() {
let host = RecordingProcessHost::default();
let mut handle = Record::new();
handle.insert("__handle__".to_string(), Value::String("process".into()));
handle.insert("id".to_string(), Value::String("target".into()));
let program = Program::block(vec![
Expr::SleepFor(Box::new(Expr::Number(5.0))),
Expr::Assign {
target: crate::AssignTarget::variable("payload".into()),
expr: Box::new(Expr::WaitSignal {
name: "ready".into(),
}),
},
Expr::SignalRun {
run: Box::new(Expr::Variable("run".into())),
name: "ready".into(),
payload: Box::new(Expr::Variable("payload".into())),
},
Expr::Finish(Some(Box::new(Expr::Variable("payload".into())))),
]);
let mut globals = Record::new();
globals.insert("run".to_string(), Value::Record(Arc::new(handle)));
let mut state = State::from_snapshot(Snapshot { globals });
let compiled = compile_program(&program);
let outcome = execute_compiled_process(&compiled, &mut state, &host)
.await
.expect("process lifecycle controls should run");
assert_eq!(
outcome,
ExecutionOutcome::Finished(Value::String("signal-payload".into()))
);
let sleeps = host.sleeps.lock().expect("sleeps lock");
assert_eq!(sleeps.len(), 1);
assert_eq!(sleeps[0].kind, SleepKind::For);
assert_eq!(sleeps[0].value, Value::Number(5.0));
let signals = host.signals.lock().expect("signals lock");
assert_eq!(signals.len(), 1);
assert_eq!(signals[0].payload, Value::String("signal-payload".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn process_fail_returns_terminal_failure_outcome() {
let host = RecordingProcessHost::default();
let program = Program::block(vec![Expr::Fail(Box::new(Expr::Record(vec![(
"reason".into(),
Expr::String("bad".into()),
)])))]);
let mut state = State::new();
let compiled = compile_program(&program);
let outcome = execute_compiled_process(&compiled, &mut state, &host)
.await
.expect("process fail should run");
let ExecutionOutcome::Failed(value) = outcome else {
panic!("expected process failure");
};
let failure = value
.as_record()
.expect("failure should preserve raw value");
assert_eq!(failure["reason"], Value::String("bad".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn process_mode_falling_off_end_finishes_null() {
let host = RecordingProcessHost::default();
let program = Program::block(vec![Expr::String("ignored".into())]);
let compiled = compile_program(&program);
let mut state = State::new();
let outcome = execute_compiled_process(&compiled, &mut state, &host)
.await
.expect("process should run");
assert_eq!(outcome, ExecutionOutcome::Finished(Value::Null));
}
#[tokio::test(flavor = "current_thread")]
async fn foreground_rejects_programmatic_processess() {
for (keyword, stmt) in [
("yield", Expr::Yield(Box::new(Expr::String("event".into())))),
("wake", Expr::Wake(Box::new(Expr::String("event".into())))),
(
"wait_signal",
Expr::WaitSignal {
name: "ready".into(),
},
),
(
"finish",
Expr::Finish(Some(Box::new(Expr::String("done".into())))),
),
("fail", Expr::Fail(Box::new(Expr::String("bad".into())))),
] {
let program = Program::block(vec![stmt]);
let mut state = State::new();
let host = RecordingProcessHost::default();
let err = execute_program(&program, &mut state, &host)
.await
.expect_err("foreground mode should reject process admins");
assert_eq!(
err,
RuntimeError::SessionProcessAdminOutsideProcess { keyword }
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn foreground_allows_signal_run() {
let program = Program::block(vec![Expr::SignalRun {
run: Box::new(Expr::String("handle".into())),
name: "ready".into(),
payload: Box::new(Expr::String("ping".into())),
}]);
let mut state = State::new();
let host = RecordingProcessHost::default();
execute_program(&program, &mut state, &host)
.await
.expect("foreground signal_run should be allowed");
let signals = host.signals.lock().expect("signals lock");
assert_eq!(signals.len(), 1);
assert_eq!(signals[0].name, "ready");
assert_eq!(signals[0].payload, Value::String("ping".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn foreground_sleep_runs_as_regular_effect() {
let host = RecordingProcessHost::default();
let program = Program::block(vec![Expr::SleepFor(Box::new(Expr::Number(1.0)))]);
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &host)
.await
.expect("foreground sleep should run");
assert_eq!(outcome, ExecutionOutcome::Continued);
let sleeps = host.sleeps.lock().expect("sleeps lock");
assert_eq!(sleeps.len(), 1);
assert_eq!(sleeps[0].kind, SleepKind::For);
}
#[tokio::test(flavor = "current_thread")]
async fn process_mode_rejects_programmatic_foreground_controls() {
for (keyword, stmt) in [
(
"submit",
Expr::Submit(Some(Box::new(Expr::String("done".into())))),
),
("print", Expr::Print(Box::new(Expr::String("debug".into())))),
] {
let program = Program::block(vec![stmt]);
let compiled = compile_program(&program);
let mut state = State::new();
let host = RecordingProcessHost::default();
let err = execute_compiled_process(&compiled, &mut state, &host)
.await
.expect_err("process mode should reject foreground controls");
assert_eq!(
err,
RuntimeError::ForegroundControlInsideProcess { keyword }
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn sync_steps_resume_correctly_after_tool_effects() {
let value = exec(
r#"
before = 20 + 2
echoed = await tools.echo({ value: before })?
after = echoed + 1
submit [before, echoed, after]
"#,
)
.await
.expect("program should run");
assert_eq!(
value,
Value::List(
vec![
Value::Number(22.0),
Value::Number(22.0),
Value::Number(23.0)
]
.into()
)
);
}
#[tokio::test(flavor = "current_thread")]
async fn traced_started_tool_errors_keep_original_instruction_span() {
let source = r#"
before = 1
value = await tools.err({})?
submit value
"#;
let compiled = compile_source(source).expect("program should compile");
let mut state = State::new();
let failure = execute_compiled_traced(&compiled, &mut state, &Host)
.await
.expect_err("unwrapped module operation error should fail");
let message = crate::format_runtime_diagnostic(source, &failure.error, failure.span);
assert!(
message.contains("`?` unwrapped failed module operation: boom"),
"{message}"
);
assert!(message.contains("--> line 3, column 9"), "{message}");
assert!(
message.contains("value = await tools.err({})?"),
"{message}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn profiled_tool_effect_keeps_sync_instruction_counts() {
let source = r#"
before = 20 + 2
echoed = await tools.echo({ value: before })?
after = echoed + 1
submit after
"#;
let compiled = compile_source(source).expect("program should compile");
let mut state = State::new();
let (_outcome, report) = profile_compiled(&compiled, &mut state, &Host)
.await
.expect("profile should succeed");
let count = |name| {
report
.instruction_stats()
.iter()
.find(|stat| stat.name == name)
.map_or(0, |stat| stat.count)
};
assert!(
count("resource_call") > 0,
"{:?}",
report.instruction_stats()
);
assert!(count("binary") > 0, "{:?}", report.instruction_stats());
assert!(count("load_name") > 0, "{:?}", report.instruction_stats());
assert!(count("store_name") >= 3, "{:?}", report.instruction_stats());
}
#[tokio::test(flavor = "current_thread")]
async fn await_unknown_handle_reports_runtime_error() {
let program = crate::parse(
r#"
result = await 1
submit result
"#,
)
.expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &AsyncHost)
.await
.expect("program should run");
let ExecutionOutcome::Finished(value) = outcome else {
panic!("expected finish");
};
let record = value
.as_record()
.expect("await should return wrapped error");
assert_eq!(record["ok"], Value::Bool(false));
assert_eq!(
record["error"],
Value::String("expected handle record".into())
);
}
#[tokio::test(flavor = "current_thread")]
async fn await_list_of_handles_returns_results_in_order() {
let program = crate::parse(
r#"
process echo(value: str) { finish value }
handles = [
start echo(value: "first"),
start echo(value: "second"),
start echo(value: "third")
]
results = await handles
submit results
"#,
)
.expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &AsyncHost)
.await
.expect("program should run");
let ExecutionOutcome::Finished(value) = outcome else {
panic!("expected finish");
};
let Value::List(results) = value else {
panic!("await list should return a list");
};
assert_eq!(results.len(), 3);
for (result, expected) in results.iter().zip(["first", "second", "third"]) {
let record = result
.as_record()
.expect("await should return wrapped result");
assert_eq!(record["ok"], Value::Bool(true));
assert_eq!(record["value"], Value::String(expected.into()));
}
}
#[tokio::test(flavor = "current_thread")]
async fn await_list_preserves_per_item_errors() {
let program = crate::parse(
r#"
process echo(value: str) { finish value }
handles = [start echo(value: "done"), 1]
results = await handles
submit results
"#,
)
.expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &AsyncHost)
.await
.expect("program should run");
let ExecutionOutcome::Finished(value) = outcome else {
panic!("expected finish");
};
let Value::List(results) = value else {
panic!("await list should return a list");
};
let ok = results[0]
.as_record()
.expect("first result should be wrapped");
assert_eq!(ok["ok"], Value::Bool(true));
assert_eq!(ok["value"], Value::String("done".into()));
let err = results[1]
.as_record()
.expect("second result should be wrapped");
assert_eq!(err["ok"], Value::Bool(false));
assert_eq!(err["error"], Value::String("expected handle record".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn await_record_of_handles_returns_record_of_wrappers() {
let program = crate::parse(
r#"
process echo(value: str) { finish value }
handles = {
first: start echo(value: "one"),
second: start echo(value: "two"),
}
results = await handles
submit [results.first?, results.second?]
"#,
)
.expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &AsyncHost)
.await
.expect("program should run");
let ExecutionOutcome::Finished(value) = outcome else {
panic!("expected finish");
};
assert_eq!(
value,
Value::List(vec![Value::String("one".into()), Value::String("two".into())].into())
);
}
#[tokio::test(flavor = "current_thread")]
async fn result_unwrap_extracts_awaited_handles_and_joined_results() {
let program = crate::parse(
r#"
process echo(value: str) { finish value }
handle = start echo(value: "done")
result = (await handle)?
submit result
"#,
)
.expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &AsyncHost)
.await
.expect("program should run");
let ExecutionOutcome::Finished(value) = outcome else {
panic!("expected finish");
};
assert_eq!(value, Value::String("done".into()));
let program = crate::parse(
r#"
process echo(value: str) { finish value }
results = await [
start echo(value: "left"),
start echo(value: "right")
]
submit [(results[0])?, (results[1])?]
"#,
)
.expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &AsyncHost)
.await
.expect("program should run");
let ExecutionOutcome::Finished(value) = outcome else {
panic!("expected finish");
};
assert_eq!(
value,
Value::List(vec![Value::String("left".into()), Value::String("right".into()),].into())
);
}
fn unwrap_schema(value: &Value) -> &Record {
crate::runtime::unwrap_type_value(value)
.and_then(Value::as_record)
.expect("Type value must unwrap to a schema record")
}
#[tokio::test(flavor = "current_thread")]
async fn type_scalar_schemas_const_fold_to_json_schema() {
for (src, expected) in [
("submit Type { v: str }", "string"),
("submit Type { v: int }", "integer"),
("submit Type { v: float }", "number"),
("submit Type { v: bool }", "boolean"),
("submit Type { v: dict }", "object"),
] {
let value = exec(src).await.expect("should succeed");
let schema = unwrap_schema(&value);
assert_eq!(schema["type"], Value::String("object".into()));
let props = schema["properties"]
.as_record()
.expect("properties must be record");
let v = props["v"].as_record().expect("field schema");
assert_eq!(v["type"], Value::String(expected.into()));
assert_eq!(
schema["additionalProperties"],
Value::Bool(false),
"additionalProperties must be false for {src}",
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn type_any_is_empty_schema() {
let value = exec("submit Type { v: any }")
.await
.expect("should succeed");
let schema = unwrap_schema(&value);
let props = schema["properties"].as_record().expect("properties");
let v = props["v"].as_record().expect("field schema");
assert!(v.is_empty(), "any must be an empty JSON Schema");
}
#[tokio::test(flavor = "current_thread")]
async fn type_enum_produces_string_with_enum_array() {
let value = exec(r#"submit Type { status: enum["ok", "err", "pending"] }"#)
.await
.expect("should succeed");
let schema = unwrap_schema(&value);
let status = schema["properties"].as_record().unwrap()["status"]
.as_record()
.expect("enum field schema");
assert_eq!(status["type"], Value::String("string".into()));
let Value::List(values) = &status["enum"] else {
panic!("enum must be a list");
};
let strings: Vec<_> = values.iter().collect();
assert_eq!(strings.len(), 3);
assert_eq!(strings[0], &Value::String("ok".into()));
assert_eq!(strings[2], &Value::String("pending".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn type_list_schema_wraps_inner_type_as_items() {
let value = exec("submit Type { tags: list[str] }")
.await
.expect("should succeed");
let schema = unwrap_schema(&value);
let tags = schema["properties"].as_record().unwrap()["tags"]
.as_record()
.expect("list field schema");
assert_eq!(tags["type"], Value::String("array".into()));
let items = tags["items"].as_record().expect("items schema");
assert_eq!(items["type"], Value::String("string".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn type_list_of_enum_preserves_nested_shape() {
let value = exec(r#"submit Type { labels: list[enum["a", "b"]] }"#)
.await
.expect("should succeed");
let schema = unwrap_schema(&value);
let labels = schema["properties"].as_record().unwrap()["labels"]
.as_record()
.expect("list schema");
let items = labels["items"].as_record().expect("enum item schema");
assert_eq!(items["type"], Value::String("string".into()));
assert!(matches!(items["enum"], Value::List(_)));
}
#[tokio::test(flavor = "current_thread")]
async fn type_nested_object_is_full_subschema() {
let value = exec(
r#"
submit Type {
title: str,
meta: Type {
pages: int,
published: int
}
}
"#,
)
.await
.expect("should succeed");
let schema = unwrap_schema(&value);
let meta = schema["properties"].as_record().unwrap()["meta"]
.as_record()
.expect("nested object schema");
assert_eq!(meta["type"], Value::String("object".into()));
let sub_props = meta["properties"].as_record().unwrap();
assert_eq!(
sub_props["pages"].as_record().unwrap()["type"],
Value::String("integer".into())
);
let required = match &meta["required"] {
Value::List(items) => items,
_ => panic!("required must be list"),
};
assert_eq!(required.len(), 2);
}
#[tokio::test(flavor = "current_thread")]
async fn type_optional_field_drops_from_required() {
let value = exec("submit Type { a: str, b: int? }")
.await
.expect("should succeed");
let schema = unwrap_schema(&value);
let required = match &schema["required"] {
Value::List(items) => items,
_ => panic!("required must be list"),
};
assert_eq!(required.len(), 1);
assert_eq!(required[0], Value::String("a".into()));
let props = schema["properties"].as_record().unwrap();
assert!(props.get("b").is_some());
}
#[tokio::test(flavor = "current_thread")]
async fn type_ref_resolves_previously_defined_type() {
let src = r#"
Inner = Type { count: int }
Outer = Type { name: str, nested: Inner }
submit Outer
"#;
let value = exec(src).await.expect("should succeed");
let schema = unwrap_schema(&value);
let nested = schema["properties"].as_record().unwrap()["nested"]
.as_record()
.expect("nested resolved schema");
assert_eq!(nested["type"], Value::String("object".into()));
let nested_props = nested["properties"].as_record().unwrap();
assert_eq!(
nested_props["count"].as_record().unwrap()["type"],
Value::String("integer".into())
);
}
#[tokio::test(flavor = "current_thread")]
async fn type_ref_to_non_type_value_is_type_error() {
let err = exec(
r#"
Inner = { count: 5 }
Outer = Type { nested: Inner }
submit Outer
"#,
)
.await
.expect_err("should fail: Inner is not a Type value");
assert!(
matches!(err, RuntimeError::TypeError { .. }),
"expected TypeError, got {err:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn type_ref_with_undefined_name_is_undefined_variable() {
let err = exec("submit Type { nested: MissingType }")
.await
.expect_err("unknown ref should fail");
assert_eq!(
err,
RuntimeError::UndefinedVariable {
name: "MissingType".to_string()
}
);
}
#[tokio::test(flavor = "current_thread")]
async fn compile_stats_count_const_folded_and_dynamic_literals() {
let src = r#"
Inner = Type { n: int }
A = Type { x: str }
B = Type { nested: Inner }
submit B
"#;
let compiled = compile_source(src).expect("should compile");
let stats = compiled.compile_stats();
assert_eq!(stats.type_literals_total, 3);
assert_eq!(
stats.type_literals_const_folded, 3,
"Inner, A, and B are constant"
);
assert_eq!(stats.type_literals_dynamic, 0);
assert_eq!(stats.type_ref_sites, 0);
}
#[tokio::test(flavor = "current_thread")]
async fn profile_report_shows_resolve_type_ref_counts() {
let src = r#"
Inner = await tools.echo({ value: Type { n: int } })?
Outer = Type { nested: Inner }
limit = await tools.echo({ value: 1 })?
numbers = push(range(limit), limit)
checked = validate({ nested: { n: numbers[0] } }, Outer)
submit checked
"#;
let compiled = compile_source(src).expect("should compile");
let mut state = State::new();
let (_outcome, report) = profile_compiled(&compiled, &mut state, &Host)
.await
.expect("profile should succeed");
let names: Vec<_> = report.instruction_stats().iter().map(|s| s.name).collect();
assert!(
names.contains(&"resolve_type_ref"),
"profile should track resolve_type_ref: {names:?}"
);
assert!(
names.contains(&"wrap_type_literal"),
"profile should track wrap_type_literal: {names:?}"
);
let builtin_names: Vec<_> = report.builtin_stats().iter().map(|s| s.name).collect();
assert!(
builtin_names.contains(&"validate"),
"profile should track validate: {builtin_names:?}"
);
assert!(
builtin_names.contains(&"range"),
"profile should track range: {builtin_names:?}"
);
assert!(
builtin_names.contains(&"push"),
"profile should track push: {builtin_names:?}"
);
assert_eq!(report.compile_stats().type_literals_total, 2);
}
#[tokio::test(flavor = "current_thread")]
async fn type_literal_inside_resource_operation_args_passes_through_as_record() {
struct CaptureHost {
captured: std::sync::Mutex<Option<Value>>,
}
impl ExecutionHost for CaptureHost {
async fn perform(&self, op: AbilityOp) -> Result<AbilityResult, ExecutionHostError> {
match op {
AbilityOp::ResourceOperation(operation) => {
if operation.operation == "spawn" {
let schema = operation
.args
.first()
.and_then(Value::as_record)
.and_then(|record| record.get("output"))
.cloned()
.expect("output arg must be present");
*self.captured.lock().unwrap() = Some(schema);
return Ok(AbilityResult::Value(Value::Null));
}
Err(ExecutionHostError::new(format!(
"unknown: {}",
operation.operation
)))
}
AbilityOp::Submit(value) | AbilityOp::Finish(value) | AbilityOp::Fail(value) => {
Ok(AbilityResult::Value(value))
}
_ => Err(ExecutionHostError::new("unsupported host ability")),
}
}
}
let host = CaptureHost {
captured: std::sync::Mutex::new(None),
};
let program = crate::parse(
r#"
Shape = Type { name: str, tags: list[str] }
await tools.spawn({ output: Shape })
submit null
"#,
)
.expect("should parse");
let mut state = State::new();
execute_program(&program, &mut state, &host)
.await
.expect("should run");
let captured = host.captured.lock().unwrap().clone().expect("captured");
let inner = crate::runtime::unwrap_type_value(&captured).expect("has $lash_type");
let schema = inner.as_record().expect("schema record");
assert_eq!(schema["type"], Value::String("object".into()));
}
#[tokio::test(flavor = "current_thread")]
async fn duplicate_field_name_is_parse_error() {
let err = crate::parse("x = Type { a: str, a: int }").expect_err("duplicate field");
let message = format!("{err}");
assert!(message.contains("duplicate field"), "{message}");
}
#[tokio::test(flavor = "current_thread")]
async fn empty_enum_is_parse_error() {
let err = crate::parse("x = Type { status: enum[] }").expect_err("empty enum");
let message = format!("{err}");
assert!(message.contains("enum"), "{message}");
}
#[tokio::test(flavor = "current_thread")]
async fn unknown_type_constructor_becomes_ref_not_error_at_parse() {
let program = crate::parse("submit Type { x: Unknown }").expect("should parse as ref");
let Expr::Block(expressions) = program.main else {
panic!("program should be a block");
};
assert!(matches!(expressions.last(), Some(Expr::Submit(_))));
}
#[tokio::test(flavor = "current_thread")]
async fn lash_type_wrapper_survives_round_trip_through_json() {
let value = exec("submit Type { n: int }")
.await
.expect("should succeed");
let json = crate::runtime::to_json(&value);
let recovered = crate::runtime::from_json(json);
let schema = crate::runtime::unwrap_type_value(&recovered)
.and_then(Value::as_record)
.expect("round-trip must preserve wrapper");
assert_eq!(schema["type"], Value::String("object".into()));
}
fn projected_record_bindings(name: &str, record: serde_json::Value) -> ProjectedBindings {
let mut projected = ProjectedBindings::new();
projected.insert(
name,
ProjectedValue::scalar(name.to_string(), crate::runtime::from_json(record)),
);
projected
}
#[tokio::test(flavor = "current_thread")]
async fn field_access_on_projected_record_returns_projected() {
let projected = projected_record_bindings(
"input",
serde_json::json!({ "prompt": "hello", "depth": 3 }),
);
let (value, _) = exec_with_projected("submit input.prompt", &projected)
.await
.expect("projected field read");
assert!(
matches!(value, Value::Projected(_)),
"expected `input.prompt` to stay projected, got {value:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn nested_field_access_keeps_projection() {
let projected =
projected_record_bindings("cfg", serde_json::json!({ "options": { "timeout": 30 } }));
let (value, _) = exec_with_projected("submit cfg.options.timeout", &projected)
.await
.expect("nested projected field read");
assert!(
matches!(value, Value::Projected(_)),
"expected nested field to stay projected, got {value:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn index_on_projected_list_returns_projected() {
let projected =
projected_record_bindings("items", serde_json::json!(["alpha", "beta", "gamma"]));
let (value, _) = exec_with_projected("submit items[1]", &projected)
.await
.expect("projected index read");
assert!(
matches!(value, Value::Projected(_)),
"expected `items[1]` to stay projected, got {value:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn computation_strips_projection() {
let projected = projected_record_bindings("input", serde_json::json!({ "n": 7 }));
let (value, _) = exec_with_projected("submit input.n + 1", &projected)
.await
.expect("computed value");
assert!(
!matches!(value, Value::Projected(_)),
"computation should strip projection, got {value:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn record_literal_preserves_per_entry_projection() {
let projected = projected_record_bindings("input", serde_json::json!({ "prompt": "hello" }));
let (value, _) = exec_with_projected(
"g = 42\nsubmit { proj: input.prompt, glob: g, lit: 99 }",
&projected,
)
.await
.expect("record literal");
let Value::Record(record) = value else {
panic!("expected record");
};
assert!(
matches!(record.get("proj"), Some(Value::Projected(_))),
"expected `proj` entry to stay projected, got {:?}",
record.get("proj")
);
assert!(
!matches!(record.get("glob"), Some(Value::Projected(_))),
"global `glob` should not be projected, got {:?}",
record.get("glob")
);
assert!(
!matches!(record.get("lit"), Some(Value::Projected(_))),
"literal `lit` should not be projected, got {:?}",
record.get("lit")
);
}
#[derive(Clone, Copy)]
enum TerminatorMode {
Identity,
Transform,
Err,
Unit,
}
struct TerminatorHost {
mode: TerminatorMode,
observed: Mutex<Vec<AbilityOp>>,
}
impl TerminatorHost {
fn new(mode: TerminatorMode) -> Self {
Self {
mode,
observed: Mutex::new(Vec::new()),
}
}
}
impl ExecutionHost for TerminatorHost {
async fn perform(&self, op: AbilityOp) -> Result<AbilityResult, ExecutionHostError> {
match op {
AbilityOp::Submit(value) | AbilityOp::Finish(value) | AbilityOp::Fail(value) => {
let observed = match &value {
Value::Number(n) => AbilityOp::Submit(Value::Number(*n)),
other => AbilityOp::Submit(other.clone()),
};
self.observed.lock().expect("observed").push(observed);
match self.mode {
TerminatorMode::Identity => Ok(AbilityResult::Value(value)),
TerminatorMode::Transform => match value {
Value::Number(n) => Ok(AbilityResult::Value(Value::Number(n + 100.0))),
other => Ok(AbilityResult::Value(other)),
},
TerminatorMode::Err => Err(ExecutionHostError::new("handler refused")),
TerminatorMode::Unit => Ok(AbilityResult::Unit),
}
}
_ => Err(ExecutionHostError::new("unsupported host ability")),
}
}
}
async fn run_with_terminator_host(
source: &str,
mode: TerminatorMode,
) -> (Result<ExecutionOutcome, RuntimeError>, Vec<AbilityOp>) {
let host = TerminatorHost::new(mode);
let program = crate::parse(source).expect("program should parse");
let mut state = State::new();
let outcome = execute_program(&program, &mut state, &host).await;
let observed = host.observed.lock().expect("observed").clone();
(outcome, observed)
}
async fn run_process_with_terminator_host(
program: Program,
mode: TerminatorMode,
) -> (Result<ExecutionOutcome, RuntimeError>, Vec<AbilityOp>) {
let host = TerminatorHost::new(mode);
let compiled = compile_program(&program);
let mut state = State::new();
let outcome = execute_compiled_process(&compiled, &mut state, &host).await;
let observed = host.observed.lock().expect("observed").clone();
(outcome, observed)
}
#[tokio::test(flavor = "current_thread")]
async fn submit_routes_through_host() {
let (outcome, observed) = run_with_terminator_host("submit 7", TerminatorMode::Identity).await;
assert_eq!(
outcome.expect("submit should succeed"),
ExecutionOutcome::Finished(Value::Number(7.0))
);
assert_eq!(observed.len(), 1, "host should observe one terminator op");
assert!(matches!(observed[0], AbilityOp::Submit(Value::Number(n)) if n == 7.0));
}
#[tokio::test(flavor = "current_thread")]
async fn host_transforms_submit_value() {
let (outcome, _) = run_with_terminator_host("submit 7", TerminatorMode::Transform).await;
assert_eq!(
outcome.expect("submit should succeed"),
ExecutionOutcome::Finished(Value::Number(107.0)),
"handler should transform the submit value before the VM unwinds"
);
}
#[tokio::test(flavor = "current_thread")]
async fn host_error_during_submit_propagates_as_runtime_error() {
let (outcome, _) = run_with_terminator_host("submit 7", TerminatorMode::Err).await;
let err = outcome.expect_err("host error should surface");
let message = err.to_string();
assert!(message.contains("submit failed"), "{message}");
assert!(message.contains("handler refused"), "{message}");
}
#[tokio::test(flavor = "current_thread")]
async fn host_returning_unit_for_submit_errors_cleanly() {
let (outcome, _) = run_with_terminator_host("submit 7", TerminatorMode::Unit).await;
let err = outcome.expect_err("unit result should error");
let message = err.to_string();
assert!(message.contains("submit failed"), "{message}");
assert!(message.contains("returned no value"), "{message}");
}
#[tokio::test(flavor = "current_thread")]
async fn finish_routes_through_host_in_process_mode() {
let program = Program::block(vec![Expr::Finish(Some(Box::new(Expr::Number(7.0))))]);
let (outcome, observed) =
run_process_with_terminator_host(program, TerminatorMode::Transform).await;
assert_eq!(
outcome.expect("finish should succeed"),
ExecutionOutcome::Finished(Value::Number(107.0))
);
assert_eq!(observed.len(), 1);
}
#[tokio::test(flavor = "current_thread")]
async fn fail_routes_through_host_and_carries_failed_outcome() {
let program = Program::block(vec![Expr::Fail(Box::new(Expr::String("boom".into())))]);
let (outcome, observed) =
run_process_with_terminator_host(program, TerminatorMode::Identity).await;
assert_eq!(
outcome.expect("fail should produce an outcome"),
ExecutionOutcome::Failed(Value::String("boom".into()))
);
assert_eq!(observed.len(), 1);
}
#[tokio::test(flavor = "current_thread")]
async fn host_can_transform_fail_value_while_keeping_failure_path() {
let program = Program::block(vec![Expr::Fail(Box::new(Expr::Number(7.0)))]);
let (outcome, _) = run_process_with_terminator_host(program, TerminatorMode::Transform).await;
assert_eq!(
outcome.expect("fail should produce an outcome"),
ExecutionOutcome::Failed(Value::Number(107.0)),
"transformed value should still arrive on the failure path"
);
}