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