Skip to main content

lashlang/
lib.rs

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