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