Skip to main content

lashlang/
lib.rs

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