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}