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}