lashlang 0.1.0-alpha.112

Lashlang: compact CodeAct language for model-authored REPL blocks in the lash agent runtime.
Documentation
use super::*;
use crate::{
    LashlangAbilities, LashlangHostCatalog, LashlangHostEnvironment, LashlangLanguageFeatures,
    LinkedModule, NamedDataType, TypeField, canonical_program_ir, parse,
};

fn host_catalog() -> LashlangHostCatalog {
    let mut catalog = LashlangHostCatalog::new();
    catalog.add_module_operation(
        ["tools"],
        "Tools",
        "read_file",
        "read_file",
        TypeExpr::Object(vec![TypeField {
            name: "path".into(),
            ty: TypeExpr::Str,
            optional: false,
        }]),
        TypeExpr::Str,
    );
    catalog.add_module_operation(
        ["tools"],
        "Tools",
        "echo",
        "echo",
        TypeExpr::Any,
        TypeExpr::Any,
    );
    crate::add_trigger_resource_operations(&mut catalog);
    catalog
        .add_trigger_source_constructor(
            ["timer", "Schedule"],
            TypeExpr::Object(vec![
                TypeField {
                    name: "expr".into(),
                    ty: TypeExpr::Str,
                    optional: false,
                },
                TypeField {
                    name: "tz".into(),
                    ty: TypeExpr::Str,
                    optional: true,
                },
            ]),
            NamedDataType::object(
                "timer.Tick",
                vec![TypeField {
                    name: "fired_at".into(),
                    ty: TypeExpr::Str,
                    optional: false,
                }],
            )
            .expect("valid tick type"),
        )
        .expect("valid trigger source");
    catalog
}

fn host_environment() -> LashlangHostEnvironment {
    LashlangHostEnvironment::new(host_catalog(), LashlangAbilities::all())
        .with_language_features(LashlangLanguageFeatures::default().with_label_annotations())
}

fn assert_linked_source_round_trip(source: &str) -> LinkedModule {
    let surface = host_environment();
    let linked = LinkedModule::link(parse(source).expect("parse original"), &surface)
        .expect("link original");
    let rendered = linked
        .artifact
        .canonical_source()
        .expect("render canonical source");
    let reparsed = LinkedModule::link(parse(&rendered).expect("parse rendered"), &surface)
        .unwrap_or_else(|err| panic!("rendered source failed to link:\n{rendered}\n{err}"));
    assert_eq!(reparsed.module_ref, linked.module_ref, "{rendered}");
    assert_eq!(
        reparsed.host_requirements_ref, linked.host_requirements_ref,
        "{rendered}"
    );
    assert_eq!(
        reparsed.artifact.exports, linked.artifact.exports,
        "{rendered}"
    );
    assert_eq!(
        reparsed.artifact.canonical_ir, linked.artifact.canonical_ir,
        "{rendered}"
    );
    linked
}

#[test]
fn canonical_source_round_trips_complex_linked_module() {
    let source = r#"
    @label(title: "Handle tick", description: "Read file and return data")
    process handle_tick(tick: timer.Tick, tool: Tools) signals { stop: str } -> { ok: bool } {
      @label(title: "Read")
      text = await tool.read_file({ path: tick.fired_at })?
      if text == "" {
        finish { ok: false }
      } else if text == "stop" {
        fail "stopped"
      } else {
        signal_run(start handle_tick(tick: tick, tool: tool), "stop", text)
        finish { ok: true }
      }
    }

    process summarize(tick: timer.Tick) -> str {
      values = [1, 2, 3]
      total = 0
      for value in values {
        if value == 2 {
          continue
        }
        total = total + value
      }
      while total < 5 {
        total = total + 1
      }
      finish to_string(total)
    }

    source = timer.Schedule({ expr: "0 8 * * *", tz: "UTC" })
    handle = await triggers.register({
      source: source,
      target: summarize,
      inputs: { tick: trigger.event },
      name: "daily"
    })?
    finish { handle: handle, source: source }
    "#;

    assert_linked_source_round_trip(source);
}

#[test]
fn canonical_source_round_trips_precedence_and_literals() {
    let source = r#"
    type Payload = { "odd-key": enum["yes", "no"], maybe: str | null, list: list[int]? }

    process inspect(payload: Payload) -> any {
      value = ((1 + 2) * 3) >= 9 and not false
      picked = value ? payload."odd-key" : "fallback\nvalue"
      finish { picked: picked, type: Type { value: str | null } }
    }

    result = await tools.echo({ value: { nested: [null, true, false, 3.5] } })?
    finish result
    "#;

    assert_linked_source_round_trip(source);
}

#[test]
fn canonical_process_source_returns_focused_definition() {
    let linked = assert_linked_source_round_trip(
        r#"
        @label(title: "Worker")
        process worker(tick: timer.Tick) -> str {
          finish tick.fired_at
        }

        source = timer.Schedule({ expr: "0 8 * * *" })
        finish source
        "#,
    );
    let process_ref = linked.artifact.process_ref("worker").expect("process ref");
    let source = linked
        .artifact
        .canonical_process_source(process_ref)
        .expect("render process")
        .expect("process source");
    assert_eq!(
        source,
        r#"@label(title: "Worker")
process worker(tick: timer.Tick) -> str {
  finish tick.fired_at
}
"#
    );

    let parsed = parse(&source).expect("parse process source");
    assert_eq!(parsed.declarations.len(), 1);
    assert_eq!(
        parsed.process("worker"),
        linked.artifact.canonical_ir.process("worker")
    );
}

#[test]
fn canonical_process_source_by_name_returns_none_for_missing_process() {
    let linked = assert_linked_source_round_trip("process worker() { finish true }\nfinish true");
    assert!(
        linked
            .artifact
            .canonical_process_source_by_name("missing")
            .expect("render missing")
            .is_none()
    );
}

#[test]
fn canonical_program_source_handles_unlinked_programs_without_requirements() {
    let program = parse(
        r#"
        process scan(path: str) {
          finish path
        }

        answer = (1 + 2) * 3
        pair = 1, 2
        singleton = (answer,)
        empty = ()
        finish { pair: pair, singleton: singleton, empty: empty, scalar: (answer) }
        "#,
    )
    .expect("parse");
    let rendered = canonical_program_source(&program).expect("render source");
    assert!(rendered.contains("pair = (1, 2)"), "{rendered}");
    assert!(rendered.contains("singleton = (answer,)"), "{rendered}");
    assert!(rendered.contains("empty = ()"), "{rendered}");
    assert!(rendered.contains("scalar: answer"), "{rendered}");
    let reparsed = parse(&rendered).expect("parse rendered");
    assert_eq!(
        canonical_program_ir(reparsed),
        canonical_program_ir(program)
    );
}

#[test]
fn host_descriptor_constructor_requires_requirements_context() {
    let linked = assert_linked_source_round_trip(
        r#"source = timer.Schedule({ expr: "0 8 * * *" })
finish source"#,
    );
    let err = canonical_program_source(&linked.artifact.canonical_ir)
        .expect_err("linked constructor needs requirements");
    assert!(matches!(
        err,
        CanonicalSourceError::UnknownHostDescriptorConstructor { type_name }
            if type_name == "timer.Schedule"
    ));
}

#[test]
fn ambiguous_host_descriptor_constructor_is_rejected() {
    let mut catalog = host_catalog();
    catalog.add_value_constructor(
        ["timer", "DuplicateSchedule"],
        TypeExpr::Object(vec![]),
        TypeExpr::Ref("timer.Schedule".into()),
    );
    let requirements = HostRequirements {
        resources: catalog,
        abilities: LashlangAbilities::default(),
        language_features: LashlangLanguageFeatures::default(),
    };
    let program = Program::block(vec![Expr::Finish(Box::new(
        Expr::HostDescriptorConstructor {
            type_name: "timer.Schedule".into(),
            input: Box::new(Expr::Record(vec![(
                "expr".into(),
                Expr::String("0 8 * * *".into()),
            )])),
        },
    ))]);
    let err = canonical_program_source_with_requirements(&program, &requirements)
        .expect_err("ambiguous constructor should fail");
    assert!(matches!(
        err,
        CanonicalSourceError::AmbiguousHostDescriptorConstructor { type_name, paths }
            if type_name == "timer.Schedule"
                && paths == vec!["timer.DuplicateSchedule".to_string(), "timer.Schedule".to_string()]
    ));
}

#[test]
fn unsupported_numbers_are_rejected() {
    let program = Program::block(vec![Expr::Finish(Box::new(Expr::Number(-1.0)))]);
    let err =
        canonical_program_source(&program).expect_err("negative raw number is not sourceable");
    assert!(matches!(
        err,
        CanonicalSourceError::UnsupportedNumber { value } if value == "-1"
    ));
}

#[test]
fn non_sourceable_type_shapes_are_rejected() {
    for (ty, expected_kind) in [
        (TypeExpr::Enum(Vec::new()), "empty enum"),
        (
            TypeExpr::Process {
                input: Box::new(TypeExpr::Object(vec![])),
                output: Box::new(TypeExpr::Any),
                input_count: 2,
            },
            "multi-input process",
        ),
        (TypeExpr::Union(vec![TypeExpr::Str]), "single-variant union"),
    ] {
        let program = Program::block(vec![Expr::Finish(Box::new(Expr::TypeLiteral(Box::new(
            ty,
        ))))]);
        let err = canonical_program_source(&program).expect_err("type is not sourceable");
        assert!(matches!(
            err,
            CanonicalSourceError::NonSourceableType { kind } if kind == expected_kind
        ));
    }
}