Skip to main content

lashlang/
lib.rs

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