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}