Skip to main content

lashlang/
lib.rs

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
70/// Version of the compiled bytecode contract used for durable continuations.
71/// Increment whenever identical source/artifact identities may compile to a
72/// continuation-incompatible instruction stream.
73pub 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}