lashlang 0.1.0-alpha.1

Lashlang: compact CodeAct language for model-authored REPL blocks in the lash agent runtime.
Documentation
mod ast;
mod lexer;
mod parser;
mod runtime;

pub use ast::{
    AssignPathStep, AssignTarget, BinaryOp, CallExpr, Expr, NamedParallelBranch, ParallelBranches,
    Program, Stmt, TypeExpr, TypeField, UnaryOp,
};
pub use lexer::{LexError, Span, Token, TokenKind, lex};
pub use parser::{ParseError, parse};
pub use runtime::{
    CompileStats, CompiledProgram, CompiledProgramCache, CompiledProgramCacheStats,
    ExecutionOutcome, ExecutionScratch, ImageValue, LASH_TYPE_KEY, ListValue, ProfileReport,
    ProfileStat, ProjectedBindingError, ProjectedBindings, ProjectedFuture, ProjectedHostValue,
    ProjectedReadRequest, ProjectedReadResponse, ProjectedValue, Record, RuntimeError,
    RuntimeFailure, Snapshot, State, ToolHost, ToolHostCall, ToolHostError, Value, compile_program,
    compile_source, execute_compiled, execute_compiled_traced,
    execute_compiled_traced_with_projected_bindings, execute_compiled_traced_with_scratch,
    execute_compiled_traced_with_scratch_and_projected_bindings,
    execute_compiled_with_projected_bindings, execute_compiled_with_scratch,
    execute_compiled_with_scratch_and_projected_bindings, execute_program, from_json, prewarm,
    profile_compiled, profile_compiled_with_projected_bindings, profile_compiled_with_scratch,
    profile_compiled_with_scratch_and_projected_bindings, unwrap_type_value,
};

pub async fn execute<H: ToolHost>(
    source: &str,
    state: &mut State,
    host: &H,
) -> Result<ExecutionOutcome, ExecuteError> {
    let program = parse(source)?;
    execute_program(&program, state, host)
        .await
        .map_err(ExecuteError::Runtime)
}

pub async fn execute_with_diagnostics<H: ToolHost>(
    source: &str,
    state: &mut State,
    host: &H,
) -> Result<ExecutionOutcome, ExecuteError> {
    let compiled = compile_source(source)?;
    execute_compiled_traced(&compiled, state, host)
        .await
        .map_err(|failure| {
            ExecuteError::Runtime(RuntimeError::ValueError {
                message: format_runtime_diagnostic(source, &failure.error, failure.span),
            })
        })
}

pub async fn execute_with_projected_bindings<H: ToolHost>(
    source: &str,
    state: &mut State,
    host: &H,
    projected: &ProjectedBindings,
) -> Result<ExecutionOutcome, ExecuteError> {
    let compiled = compile_source(source)?;
    execute_compiled_with_projected_bindings(&compiled, state, host, projected)
        .await
        .map_err(ExecuteError::Runtime)
}

pub fn format_parse_diagnostic(source: &str, error: &ParseError) -> String {
    format_source_diagnostic(
        source,
        error.offset(),
        &error.to_string(),
        parse_hint(error),
    )
}

pub fn format_runtime_diagnostic(source: &str, error: &RuntimeError, span: Option<Span>) -> String {
    let Some(span) = span else {
        return format_message_with_hint(&error.to_string(), runtime_hint(error));
    };
    format_source_diagnostic(source, span.start, &error.to_string(), runtime_hint(error))
}

fn format_source_diagnostic(
    source: &str,
    offset: usize,
    message: &str,
    hint: Option<&'static str>,
) -> String {
    let (line, column, source_line) = line_column_snippet(source, offset);
    let caret_pad = " ".repeat(column.saturating_sub(1));
    let mut diagnostic =
        format!("{message}\n--> line {line}, column {column}\n{source_line}\n{caret_pad}^");
    if let Some(hint) = hint {
        diagnostic.push_str("\nhint: ");
        diagnostic.push_str(hint);
    }
    diagnostic
}

fn format_message_with_hint(message: &str, hint: Option<&'static str>) -> String {
    let mut diagnostic = message.to_string();
    if let Some(hint) = hint {
        diagnostic.push_str("\nhint: ");
        diagnostic.push_str(hint);
    }
    diagnostic
}

fn parse_hint(error: &ParseError) -> Option<&'static str> {
    match error {
        ParseError::Unexpected { found, .. } if found == "`if`" => {
            Some("use `cond ? yes : no` for inline conditionals")
        }
        ParseError::Unexpected { found, .. } if found == "`for`" => Some(
            "`for` is a statement. Put it on its own line, not inside an expression or record literal.",
        ),
        ParseError::UnsupportedLoop {
            keyword: "while", ..
        } => Some("use bounded `for` loops over ranges or lists"),
        ParseError::Expected { expected, .. } if expected.contains("type literals must start") => {
            Some("write nested object types as `Type { field: type }`")
        }
        _ => None,
    }
}

fn runtime_hint(error: &RuntimeError) -> Option<&'static str> {
    match error {
        RuntimeError::TypeError { message } | RuntimeError::ValueError { message } => {
            if message.starts_with("`?` unwrapped failed tool result:") {
                return Some(
                    "remove `?` and inspect `.ok` or `.error` when you need to handle failures",
                );
            }
            if message.contains("read-only projected binding") {
                return Some("copy the projected value into a new variable before changing it");
            }
            if message == "`validate` requires a Type literal as the second argument" {
                return Some("pass `Type { ... }` or a variable that holds a Type literal");
            }
            None
        }
        _ => None,
    }
}

fn line_column_snippet(source: &str, offset: usize) -> (usize, usize, String) {
    let offset = offset.min(source.len());
    let mut line = 1usize;
    let mut line_start = 0usize;
    for (idx, ch) in source.char_indices() {
        if idx >= offset {
            break;
        }
        if ch == '\n' {
            line += 1;
            line_start = idx + ch.len_utf8();
        }
    }
    let column = source[line_start..offset].chars().count() + 1;
    let line_end = source[offset..]
        .find('\n')
        .map(|rel| offset + rel)
        .unwrap_or(source.len());
    (
        line,
        column,
        source[line_start..line_end]
            .trim_end_matches('\r')
            .to_string(),
    )
}

#[derive(Debug, thiserror::Error, PartialEq)]
pub enum ExecuteError {
    #[error(transparent)]
    Parse(#[from] ParseError),
    #[error(transparent)]
    Runtime(#[from] RuntimeError),
}

#[cfg(test)]
mod tests {
    use super::*;

    struct Host;

    impl ToolHost for Host {
        async fn call(&self, _name: String, _args: Record) -> Result<Value, ToolHostError> {
            Ok(Value::Null)
        }
    }

    #[tokio::test(flavor = "current_thread")]
    async fn execute_wraps_parse_errors() {
        let mut state = State::new();
        let err = execute("if true", &mut state, &Host)
            .await
            .expect_err("parse should fail");
        assert!(matches!(err, ExecuteError::Parse(_)));
    }

    #[tokio::test(flavor = "current_thread")]
    async fn execute_wraps_runtime_errors() {
        let mut state = State::new();
        let err = execute("submit missing", &mut state, &Host)
            .await
            .expect_err("runtime should fail");
        assert!(matches!(err, ExecuteError::Runtime(_)));
    }

    #[tokio::test(flavor = "current_thread")]
    async fn execute_with_diagnostics_includes_source_location() {
        let mut state = State::new();
        let err = execute_with_diagnostics("x = 1\nsubmit missing", &mut state, &Host)
            .await
            .expect_err("runtime should fail");
        let message = diagnostic_message(err);
        assert!(message.contains("unknown name `missing`"), "{message}");
        assert!(message.contains("--> line 2, column 1"), "{message}");
        assert!(message.contains("submit missing"), "{message}");
        assert!(message.contains("^"), "{message}");
    }

    #[tokio::test(flavor = "current_thread")]
    async fn compile_source_prewarm_and_traced_scratch_execution_work_together() {
        prewarm();
        let compiled = compile_source("submit 7").expect("source should compile");
        let mut state = State::new();
        let mut scratch = ExecutionScratch::new();
        let outcome =
            execute_compiled_traced_with_scratch(&compiled, &mut state, &Host, &mut scratch)
                .await
                .expect("execution should succeed");
        assert_eq!(outcome, ExecutionOutcome::Finished(Value::Number(7.0)));
    }

    #[test]
    fn compiled_program_cache_reuses_source_and_tracks_lru_stats() {
        let mut cache = CompiledProgramCache::with_capacity(2);
        let first = cache.get_or_compile("submit 1").expect("compile first");
        let second = cache.get_or_compile("submit 1").expect("compile cache hit");
        let other = cache.get_or_compile("submit 2").expect("compile second");
        let third = cache.get_or_compile("submit 3").expect("compile third");

        assert!(std::sync::Arc::ptr_eq(&first, &second));
        assert!(!std::sync::Arc::ptr_eq(&first, &other));
        assert!(!std::sync::Arc::ptr_eq(&other, &third));

        let stats = cache.stats();
        assert_eq!(stats.hits, 1);
        assert_eq!(stats.misses, 3);
        assert_eq!(stats.evictions, 1);
        assert_eq!(stats.entries, 2);
        assert_eq!(stats.capacity, 2);
    }

    #[tokio::test(flavor = "current_thread")]
    async fn execute_with_diagnostics_covers_representative_runtime_failures() {
        let cases = [
            (
                "x = 1\nsubmit ({ ok: false, error: \"boom\" })?",
                "`?` unwrapped failed tool result: boom",
                "submit ({ ok: false, error: \"boom\" })?",
            ),
            (
                "x = 1\nsubmit len(true)",
                "`len` requires a string, list, record, or null",
                "submit len(true)",
            ),
            (
                "x = 1\nsubmit \"text\".field",
                "can't read `.field` from string",
                "submit \"text\".field",
            ),
            ("x = 1\nsubmit 7[0]", "can't index number", "submit 7[0]"),
        ];

        for (source, expected_error, expected_snippet) in cases {
            let mut state = State::new();
            let err = execute_with_diagnostics(source, &mut state, &Host)
                .await
                .expect_err("runtime should fail");
            let message = diagnostic_message(err);
            assert!(message.contains(expected_error), "{message}");
            assert!(message.contains("--> line 2, column 1"), "{message}");
            assert!(message.contains(expected_snippet), "{message}");
        }
    }

    #[tokio::test(flavor = "current_thread")]
    async fn execute_success_path_uses_host() {
        let mut state = State::new();
        let outcome = execute("v = call anything {} submit v", &mut state, &Host)
            .await
            .expect("should succeed");
        let ExecutionOutcome::Finished(value) = outcome else {
            panic!("expected finish");
        };
        assert_eq!(
            value.as_record().expect("tool result should be record")["ok"],
            Value::Bool(true)
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn execute_allows_bare_finish() {
        let mut state = State::new();
        let outcome = execute("submit", &mut state, &Host)
            .await
            .expect("should succeed");
        let ExecutionOutcome::Finished(value) = outcome else {
            panic!("expected finish");
        };
        assert_eq!(value, Value::Null);
    }

    fn diagnostic_message(err: ExecuteError) -> String {
        let ExecuteError::Runtime(RuntimeError::ValueError { message }) = err else {
            panic!("expected diagnostic runtime error");
        };
        message
    }
}