1mod artifact;
2mod ast;
3mod builtins;
4mod compile;
5mod identity;
6mod introspection;
7mod lexer;
8mod linker;
9mod parser;
10mod runtime;
11mod source;
12mod tracking;
13mod trigger;
14mod workflow_graph;
15
16#[cfg(any(test, feature = "testing"))]
17pub mod testing;
18
19pub use artifact::{
20 ArtifactStoreError, ContentHash, DurabilityTier, HostRequirements, HostRequirementsRef,
21 InMemoryLashlangArtifactStore, LASHLANG_COMPILER_VERSION, LASHLANG_SEMANTIC_HASH_VERSION,
22 LASHLANG_VM_ABI_VERSION, LashlangArtifactStore, ModuleArtifact, ModuleArtifactError,
23 ModuleExports, ModuleRef, ProcessRef, canonical_program_ir,
24 global_in_memory_lashlang_artifact_store, host_requirements_for_program,
25};
26pub use ast::{
27 AssignPathStep, AssignTarget, BinaryOp, Declaration, Expr, ExprFolder, ExprVisitor,
28 LabelMetadata, ListComprehensionClause, ProcessDecl, ProcessParam, ProcessStartExpr, Program,
29 ResourceRefExpr, TypeDecl, TypeExpr, TypeField, UnaryOp, fold_expr_children, format_type_expr,
30 walk_expr,
31};
32pub use compile::{
33 ModuleCompileDiagnostic, ModuleCompileError, ModuleCompileOutput, ModuleCompileRequest,
34 ModuleCompileStage, compile_module,
35};
36pub use identity::{ProcessDefinitionIdentity, ProcessDefinitionIdentityError};
37pub use introspection::{
38 ModuleInstanceIntrospection, ModuleIntrospection, ModuleIntrospectionError,
39 ModuleOperationIntrospection, NamedDataTypeIntrospection, ProcessInputIntrospection,
40 ProcessIntrospection, ProcessSignalIntrospection, ResourceOperationIntrospection,
41 ResourceTypeIntrospection, TriggerSourceIntrospection, TypeView, ValueConstructorIntrospection,
42 referenced_module_call_paths,
43};
44pub use lexer::{LexError, Span, Token, TokenKind, lex};
45pub use linker::{
46 LashlangAbilities, LashlangHostCatalog, LashlangHostCatalogError, LashlangHostEnvironment,
47 LashlangLanguageFeatures, LinkError, LinkedModule, NamedDataType, NamedDataTypeError,
48 ResourceOperationBinding, ResourceTypeCatalog, TriggerSourceBinding, ValueConstructorBinding,
49};
50pub use parser::{ParseError, parse, parse_expression};
51pub use runtime::{
52 AbilityOp, AbilityResult, BudgetedJsonProjectionConfig, BudgetedJsonProjector, CompileStats,
53 CompiledLinkedProgram, CompiledProcessCache, CompiledProcessCacheKey, CompiledProgram,
54 CompiledProgramCache, CompiledProgramCacheStats, ContinuationError, ExecutableProgram,
55 ExecutionEnvironment, ExecutionHost, ExecutionHostError, ExecutionMode, ExecutionOutcome,
56 ExecutionScratch, ImageValue, LASH_HOST_DESCRIPTOR_TYPE_KEY, LASH_HOST_DESCRIPTOR_VALUE_KEY,
57 LASH_HOST_REQUIREMENTS_REF_KEY, LASH_MODULE_REF_KEY, LASH_PROCESS_NAME_KEY,
58 LASH_PROCESS_REF_KEY, LASH_PROCESS_VALUE_KEY, LASH_TYPE_KEY, LinkedProgramCache,
59 LinkedProgramCacheError, ListValue, ProcessEvent, ProcessEventKind, ProcessSignal,
60 ProcessStart, ProfileReport, ProfileStat, ProjectedBindingError, ProjectedBindings,
61 ProjectedFuture, ProjectedHostDescriptor, ProjectedReadRequest, ProjectedReadResponse,
62 ProjectedValue, Record, ResourceHandle, ResourceOperation, ResourceOperationBatch,
63 ResourceOperationBatchResult, ResourceOperationResult, RuntimeError, RuntimeFailure, Sleep,
64 SleepKind, Snapshot, State, Value, ValueProjectionContext, ValueProjector, Vm, VmContinuation,
65 VmIteratorContinuation, VmIteratorCursor, VmProfileContinuation, VmRunOutcome, compile,
66 compile_linked, compile_linked_process, compile_module_artifact_process, compile_process,
67 execute, from_json, prewarm, unwrap_type_value,
68};
69
70pub const BYTECODE_FORMAT_VERSION: u32 = 1;
74pub use source::{
75 CanonicalSourceError, canonical_assign_target_source, canonical_expression_source,
76 canonical_process_source, canonical_process_source_with_requirements, canonical_program_source,
77 canonical_program_source_with_requirements,
78};
79pub use tracking::{
80 LashlangBranchSite, LashlangExecutionCallSite, LashlangExecutionChild,
81 LashlangExecutionObservation, LashlangExecutionSite, ProcessBranchSelection,
82 WorkflowExecutionSite, process_ref_key,
83};
84pub use trigger::{
85 HostDescriptor, HostDescriptorError, LASH_TRIGGER_EVENT_KEY, TriggerCancelRequest,
86 TriggerCompatibility, TriggerCompatibilityError, TriggerCompatibilityRequest,
87 TriggerHostOperation, TriggerInputBinding, TriggerInputTemplate, TriggerListRequest,
88 TriggerRegistrationRequest, add_trigger_resource_operations, cancel_call_args,
89 check_trigger_compatibility, event_type_for_source, is_trigger_resource_type, list_call_args,
90 register_call_args, trigger_event_placeholder_expr,
91};
92pub use workflow_graph::{
93 GraphRenderError, VariableVersion, WORKFLOW_GRAPH_SCHEMA_VERSION, WorkflowContainer,
94 WorkflowDeclaration, WorkflowEdge, WorkflowEdgeKind, WorkflowEffectKind, WorkflowGraph,
95 WorkflowGraphBuildError, WorkflowListComprehensionClause, WorkflowNode, WorkflowNodeId,
96 WorkflowNodeKind, WorkflowNodeNameSource, WorkflowProcess, WorkflowSubgraph,
97 WorkflowTerminalKind, node_id_for_execution_site, runtime_execution_site_for_workflow_site,
98 workflow_graph_from_source, workflow_graph_to_source,
99};
100
101pub fn format_parse_diagnostic(source: &str, error: &ParseError) -> String {
102 let span = error.span().unwrap_or(Span {
103 start: error.offset(),
104 end: error.offset(),
105 });
106 format_source_diagnostic(source, span, &error.to_string(), parse_hint(error))
107}
108
109pub fn format_runtime_diagnostic(source: &str, error: &RuntimeError, span: Option<Span>) -> String {
110 let Some(span) = span else {
111 return format_message_with_hint(&error.to_string(), runtime_hint(error));
112 };
113 format_source_diagnostic(source, span, &error.to_string(), runtime_hint(error))
114}
115
116pub fn format_link_diagnostic(source: &str, error: &LinkError) -> String {
117 match error.span() {
118 Some(span) => format_source_diagnostic(source, span, &error.to_string(), None),
119 None => error.to_string(),
120 }
121}
122
123fn format_source_diagnostic(
124 source: &str,
125 span: Span,
126 message: &str,
127 hint: Option<&'static str>,
128) -> String {
129 let start = span.start.min(source.len());
130 let (line, column, _line_start, line_end, source_line) = line_column_snippet(source, start);
131 let caret_pad = " ".repeat(column.saturating_sub(1));
132 let underline_len = if start < line_end {
133 let underline_end = span.end.max(start.saturating_add(1)).min(line_end);
134 source[start..underline_end].chars().count().max(1)
135 } else {
136 1
137 };
138 let underline = format!("^{}", "~".repeat(underline_len.saturating_sub(1)));
139 let mut diagnostic = format!(
140 "{message}\n--> line {line}, column {column}\n{source_line}\n{caret_pad}{underline}"
141 );
142 if let Some(hint) = hint {
143 diagnostic.push_str("\nhint: ");
144 diagnostic.push_str(hint);
145 }
146 diagnostic
147}
148
149fn format_message_with_hint(message: &str, hint: Option<&'static str>) -> String {
150 let mut diagnostic = message.to_string();
151 if let Some(hint) = hint {
152 diagnostic.push_str("\nhint: ");
153 diagnostic.push_str(hint);
154 }
155 diagnostic
156}
157
158fn parse_hint(error: &ParseError) -> Option<&'static str> {
159 match error {
160 ParseError::Unexpected { found, .. } if found == "`if`" => {
161 Some("use `cond ? yes : no` for inline conditionals")
162 }
163 ParseError::Unexpected { found, .. } if found == "`for`" => Some(
164 "`for` is a statement. Put it on its own line, not inside an expression or record literal.",
165 ),
166 ParseError::Expected { expected, .. } if expected.contains("type literals must start") => {
167 Some("write nested object types as `Type { field: type }`")
168 }
169 ParseError::DeclarativeTriggerRemoved { .. } => Some(
170 "construct a host-provided trigger source value and call the trigger registry register operation",
171 ),
172 _ => None,
173 }
174}
175
176fn runtime_hint(error: &RuntimeError) -> Option<&'static str> {
177 match error {
178 RuntimeError::TypeError { message } | RuntimeError::ValueError { message } => {
179 if message.starts_with("`?` unwrapped failed tool result:") {
180 return Some(
181 "remove `?` and inspect `.ok` or `.error` when you need to handle failures",
182 );
183 }
184 if message.contains("read-only projected binding") {
185 return Some("copy the projected value into a new variable before changing it");
186 }
187 if message == "`validate` requires a Type literal as the second argument" {
188 return Some("pass `Type { ... }` or a variable that holds a Type literal");
189 }
190 None
191 }
192 _ => None,
193 }
194}
195
196fn line_column_snippet(source: &str, offset: usize) -> (usize, usize, usize, usize, String) {
197 let offset = offset.min(source.len());
198 let mut line = 1usize;
199 let mut line_start = 0usize;
200 for (idx, ch) in source.char_indices() {
201 if idx >= offset {
202 break;
203 }
204 if ch == '\n' {
205 line += 1;
206 line_start = idx + ch.len_utf8();
207 }
208 }
209 let column = source[line_start..offset].chars().count() + 1;
210 let line_end = source[offset..]
211 .find('\n')
212 .map(|rel| offset + rel)
213 .unwrap_or(source.len());
214 (
215 line,
216 column,
217 line_start,
218 line_end,
219 source[line_start..line_end]
220 .trim_end_matches('\r')
221 .to_string(),
222 )
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 struct Host;
230
231 impl ExecutionHost for Host {
232 async fn perform(&self, op: AbilityOp) -> Result<AbilityResult, ExecutionHostError> {
233 match op {
234 AbilityOp::ResourceOperation(operation) if operation.operation == "anything" => {
235 Ok(AbilityResult::Value(Value::Record(std::sync::Arc::new(
236 Record::from_iter([("ok".to_string(), Value::Bool(true))]),
237 ))))
238 }
239 AbilityOp::ResourceOperationBatch(batch) => Ok(
240 AbilityResult::ResourceOperationBatch(ResourceOperationBatchResult {
241 results: batch
242 .operations
243 .into_iter()
244 .map(|operation| {
245 if operation.operation == "anything" {
246 ResourceOperationResult::Value(Value::Record(
247 std::sync::Arc::new(Record::from_iter([(
248 "ok".to_string(),
249 Value::Bool(true),
250 )])),
251 ))
252 } else {
253 ResourceOperationResult::Error(ExecutionHostError::new(
254 "unsupported host ability",
255 ))
256 }
257 })
258 .collect(),
259 }),
260 ),
261 AbilityOp::Finish(value) | AbilityOp::Fail(value) => {
262 Ok(AbilityResult::Value(value))
263 }
264 _ => Ok(AbilityResult::Value(Value::Null)),
265 }
266 }
267 }
268
269 #[tokio::test(flavor = "current_thread")]
270 async fn compile_reports_parse_errors() {
271 let err = compile("if true").expect_err("parse should fail");
272 assert!(matches!(err, ParseError::Expected { .. }));
273 }
274
275 #[tokio::test(flavor = "current_thread")]
276 async fn execute_reports_runtime_errors() {
277 let compiled = compile("finish missing").expect("source should compile");
278 let mut state = State::new();
279 let err = execute(&compiled, &mut state, &Host)
280 .await
281 .expect_err("runtime should fail");
282 assert!(matches!(err, RuntimeError::UndefinedVariable { .. }));
283 }
284
285 #[test]
286 fn message_hint_format_preserves_message_and_appends_hint() {
287 assert_eq!(
288 format_message_with_hint("plain failure", None),
289 "plain failure"
290 );
291 assert_eq!(
292 format_message_with_hint("tool failed", Some("inspect `.error`")),
293 "tool failed\nhint: inspect `.error`"
294 );
295 }
296
297 #[test]
298 fn removed_declarative_trigger_parse_hint_is_stable() {
299 let error = ParseError::DeclarativeTriggerRemoved {
300 span: Span { start: 0, end: 8 },
301 };
302 assert_eq!(
303 parse_hint(&error),
304 Some(
305 "construct a host-provided trigger source value and call the trigger registry register operation"
306 )
307 );
308 }
309
310 #[tokio::test(flavor = "current_thread")]
311 async fn traced_environment_records_source_location() {
312 let source = "x = 1\nfinish missing";
313 let compiled = compile(source).expect("source should compile");
314 let mut state = State::new();
315 let env = ExecutionEnvironment::new(&Host).traced();
316 execute(&compiled, &mut state, &env)
317 .await
318 .expect_err("runtime should fail");
319 let failure = env
320 .take_runtime_failure()
321 .expect("traced host should receive runtime failure");
322 let message = format_runtime_diagnostic(source, &failure.error, failure.span);
323 assert!(message.contains("unknown name `missing`"), "{message}");
324 assert!(message.contains("--> line 2, column 1"), "{message}");
325 assert!(message.contains("finish missing"), "{message}");
326 assert!(message.contains("^"), "{message}");
327 }
328
329 #[tokio::test(flavor = "current_thread")]
330 async fn compile_prewarm_and_environment_scratch_execution_work_together() {
331 prewarm();
332 let compiled = compile("finish 7").expect("source should compile");
333 let mut state = State::new();
334 let env = ExecutionEnvironment::new(&Host)
335 .traced()
336 .with_scratch(ExecutionScratch::new());
337 let outcome = execute(&compiled, &mut state, &env)
338 .await
339 .expect("execution should succeed");
340 assert_eq!(outcome, ExecutionOutcome::Finished(Value::Number(7.0)));
341 assert!(env.take_recycled_scratch().is_some());
342 }
343
344 #[test]
345 fn compiled_program_cache_reuses_source_and_tracks_lru_stats() {
346 let mut cache = CompiledProgramCache::with_capacity(2);
347 let first = cache.get_or_compile("finish 1").expect("compile first");
348 let second = cache.get_or_compile("finish 1").expect("compile cache hit");
349 let same_ast = cache
350 .get_or_compile("finish 1\n")
351 .expect("compile source-distinct program");
352 let other = cache.get_or_compile("finish 2").expect("compile second");
353 let third = cache.get_or_compile("finish 3").expect("compile third");
354
355 assert!(std::sync::Arc::ptr_eq(&first, &second));
356 assert!(!std::sync::Arc::ptr_eq(&first, &same_ast));
357 assert!(!std::sync::Arc::ptr_eq(&first, &other));
358 assert!(!std::sync::Arc::ptr_eq(&other, &third));
359
360 let stats = cache.stats();
361 assert_eq!(stats.hits, 1);
362 assert_eq!(stats.misses, 4);
363 assert_eq!(stats.evictions, 2);
364 assert_eq!(stats.entries, 2);
365 assert_eq!(stats.capacity, 2);
366 }
367
368 #[test]
369 fn linked_program_cache_reuses_source_when_host_environment_satisfies_requirements() {
370 let source = r#"finish (await tools.read_file({ path: "." }))?"#;
371 let base_environment = LashlangHostEnvironment::new(
372 LashlangHostCatalog::tool_default(["read_file"]),
373 LashlangAbilities::default(),
374 );
375 let extra_environment = LashlangHostEnvironment::new(
376 LashlangHostCatalog::tool_default(["read_file", "unrelated"]),
377 LashlangAbilities::default(),
378 );
379 let mut cache = LinkedProgramCache::with_capacity(2);
380
381 let first = cache
382 .get_or_compile(source, &base_environment)
383 .expect("compile first linked program");
384 let second = cache
385 .get_or_compile(source, &base_environment)
386 .expect("reuse same surface");
387 let extra = cache
388 .get_or_compile(source, &extra_environment)
389 .expect("reuse when unrelated tools are added");
390
391 assert!(std::sync::Arc::ptr_eq(&first, &second));
392 assert!(std::sync::Arc::ptr_eq(&first, &extra));
393 assert_eq!(
394 first.linked_module().host_requirements_ref,
395 extra.linked_module().host_requirements_ref
396 );
397
398 let stats = cache.stats();
399 assert_eq!(stats.hits, 2);
400 assert_eq!(stats.misses, 1);
401 assert_eq!(stats.evictions, 0);
402 assert_eq!(stats.entries, 1);
403 }
404
405 #[test]
406 fn linked_program_cache_keeps_source_and_host_requirements_distinct() {
407 let source = r#"finish (await tools.read_file({ path: "." }))?"#;
408 let base_environment = LashlangHostEnvironment::new(
409 LashlangHostCatalog::tool_default(["read_file"]),
410 LashlangAbilities::default(),
411 );
412 let mut changed_resources = LashlangHostCatalog::new();
413 changed_resources.add_module_operation(
414 ["tools"],
415 "Tools",
416 "read_file",
417 "read_file_v2",
418 TypeExpr::Any,
419 TypeExpr::Any,
420 );
421 let changed_environment =
422 LashlangHostEnvironment::new(changed_resources, LashlangAbilities::default());
423 let missing_environment = LashlangHostEnvironment::new(
424 LashlangHostCatalog::tool_default(["echo"]),
425 LashlangAbilities::default(),
426 );
427 let mut cache = LinkedProgramCache::with_capacity(4);
428
429 let first = cache
430 .get_or_compile(source, &base_environment)
431 .expect("compile first linked program");
432 let newline = cache
433 .get_or_compile(&format!("{source}\n"), &base_environment)
434 .expect("compile source-distinct linked program");
435 let changed = cache
436 .get_or_compile(source, &changed_environment)
437 .expect("compile changed surface requirement");
438 let missing = cache
439 .get_or_compile(source, &missing_environment)
440 .expect_err("missing resource operation should not reuse cached program");
441
442 assert!(!std::sync::Arc::ptr_eq(&first, &newline));
443 assert!(!std::sync::Arc::ptr_eq(&first, &changed));
444 assert_ne!(
445 first.linked_module().host_requirements_ref,
446 changed.linked_module().host_requirements_ref
447 );
448 assert!(matches!(
449 missing,
450 LinkedProgramCacheError::Link(LinkError::UnknownResourceOperation {
451 operation,
452 ..
453 }) if operation == "read_file"
454 ));
455
456 let stats = cache.stats();
457 assert_eq!(stats.hits, 0);
458 assert_eq!(stats.misses, 4);
459 assert_eq!(stats.evictions, 0);
460 assert_eq!(stats.entries, 3);
461 }
462
463 #[tokio::test(flavor = "current_thread")]
464 async fn execute_with_diagnostics_covers_representative_runtime_failures() {
465 let cases = [
466 (
467 "x = 1\nfinish ({ ok: false, error: \"boom\" })?",
468 "`?` unwrapped failed tool result: boom",
469 "finish ({ ok: false, error: \"boom\" })?",
470 ),
471 (
472 "x = 1\nfinish len(true)",
473 "`len` requires a string, tuple, list, record, or null",
474 "finish len(true)",
475 ),
476 (
477 "x = 1\nfinish \"text\".field",
478 "can't read `.field` from string",
479 "finish \"text\".field",
480 ),
481 ("x = 1\nfinish 7[0]", "can't index number", "finish 7[0]"),
482 ];
483
484 for (source, expected_error, expected_snippet) in cases {
485 let compiled = compile(source).expect("source should compile");
486 let mut state = State::new();
487 let env = ExecutionEnvironment::new(&Host).traced();
488 execute(&compiled, &mut state, &env)
489 .await
490 .expect_err("runtime should fail");
491 let failure = env
492 .take_runtime_failure()
493 .expect("traced host should receive runtime failure");
494 let message = format_runtime_diagnostic(source, &failure.error, failure.span);
495 assert!(message.contains(expected_error), "{message}");
496 assert!(message.contains("--> line 2, column 1"), "{message}");
497 assert!(message.contains(expected_snippet), "{message}");
498 }
499 }
500
501 #[tokio::test(flavor = "current_thread")]
502 async fn execute_success_path_uses_host() {
503 let linked = LinkedModule::link(
504 parse("v = await tools.anything({})? finish v").expect("source should parse"),
505 LashlangHostEnvironment::new(
506 LashlangHostCatalog::tool_default(["anything"]),
507 LashlangAbilities::default(),
508 ),
509 )
510 .expect("source should link");
511 let compiled = compile_linked(&linked);
512 let mut state = State::new();
513 let outcome = execute(&compiled, &mut state, &Host)
514 .await
515 .expect("should succeed");
516 let ExecutionOutcome::Finished(value) = outcome else {
517 panic!("expected finish");
518 };
519 assert_eq!(
520 value.as_record().expect("tool result should be record")["ok"],
521 Value::Bool(true)
522 );
523 }
524
525 #[tokio::test(flavor = "current_thread")]
526 async fn execute_allows_finish_null() {
527 let compiled = compile("finish null").expect("source should compile");
528 let mut state = State::new();
529 let outcome = execute(&compiled, &mut state, &Host)
530 .await
531 .expect("should succeed");
532 let ExecutionOutcome::Finished(value) = outcome else {
533 panic!("expected finish");
534 };
535 assert_eq!(value, Value::Null);
536 }
537}