harn-vm 0.8.43

Async bytecode virtual machine for the Harn programming language
Documentation
use std::collections::BTreeSet;

use serde_json::Value;

use super::manifest::{BindingManifest, BindingPolicyDisposition};

pub fn composition_typescript_declarations(manifest: &BindingManifest) -> String {
    let mut out = String::from(
        "export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };\n",
    );
    out.push_str("export type CompositionToolResult = JsonValue;\n\n");
    for binding in &manifest.bindings {
        if binding.policy.disposition != BindingPolicyDisposition::Allowed {
            continue;
        }
        let args_type = json_schema_to_typescript(&binding.input_schema);
        let result_type = binding
            .output_schema
            .as_ref()
            .map(json_schema_to_typescript)
            .unwrap_or_else(|| "CompositionToolResult".to_string());
        out.push_str(&format!(
            "export declare function {}(args: {}): Promise<{}>;\n",
            binding.binding, args_type, result_type
        ));
    }
    out
}

fn json_schema_to_typescript(schema: &Value) -> String {
    if let Some(shorthand) = schema.as_str() {
        return match shorthand {
            "string" => "string".to_string(),
            "int" | "integer" | "float" | "number" => "number".to_string(),
            "bool" | "boolean" => "boolean".to_string(),
            "list" | "array" => "JsonValue[]".to_string(),
            "dict" | "object" => "{ [key: string]: JsonValue }".to_string(),
            _ => "JsonValue".to_string(),
        };
    }
    let schema_type = schema.get("type").and_then(Value::as_str);
    match schema_type {
        Some("string") => enum_string_literals(schema).unwrap_or_else(|| "string".to_string()),
        Some("integer") | Some("number") => "number".to_string(),
        Some("boolean") => "boolean".to_string(),
        Some("array") => {
            let item_type = schema
                .get("items")
                .map(json_schema_to_typescript)
                .unwrap_or_else(|| "JsonValue".to_string());
            format!("{item_type}[]")
        }
        Some("object") | None if schema.get("properties").is_some() => {
            let required = schema
                .get("required")
                .and_then(Value::as_array)
                .map(|items| {
                    items
                        .iter()
                        .filter_map(Value::as_str)
                        .collect::<BTreeSet<_>>()
                })
                .unwrap_or_default();
            let mut fields = Vec::new();
            if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
                for (name, value) in properties {
                    let marker = if required.contains(name.as_str()) {
                        ""
                    } else {
                        "?"
                    };
                    fields.push(format!(
                        "{}{}: {}",
                        typescript_property_name(name),
                        marker,
                        json_schema_to_typescript(value)
                    ));
                }
            }
            if fields.is_empty() {
                "{ [key: string]: JsonValue }".to_string()
            } else {
                format!("{{ {} }}", fields.join("; "))
            }
        }
        None if schema.as_object().is_some() => {
            let fields = schema
                .as_object()
                .into_iter()
                .flat_map(|properties| properties.iter())
                .map(|(name, value)| {
                    let marker = if value
                        .get("required")
                        .and_then(Value::as_bool)
                        .unwrap_or(true)
                    {
                        ""
                    } else {
                        "?"
                    };
                    format!(
                        "{}{}: {}",
                        typescript_property_name(name),
                        marker,
                        json_schema_to_typescript(value)
                    )
                })
                .collect::<Vec<_>>();
            if fields.is_empty() {
                "{ [key: string]: JsonValue }".to_string()
            } else {
                format!("{{ {} }}", fields.join("; "))
            }
        }
        Some("object") => "{ [key: string]: JsonValue }".to_string(),
        _ => "JsonValue".to_string(),
    }
}

fn enum_string_literals(schema: &Value) -> Option<String> {
    let variants = schema.get("enum")?.as_array()?;
    let strings = variants
        .iter()
        .map(|value| value.as_str().map(|text| format!("{text:?}")))
        .collect::<Option<Vec<_>>>()?;
    (!strings.is_empty()).then(|| strings.join(" | "))
}

fn typescript_property_name(name: &str) -> String {
    if name.chars().enumerate().all(|(idx, ch)| {
        ch == '_' || ch.is_ascii_alphanumeric() && (idx > 0 || !ch.is_ascii_digit())
    }) {
        name.to_string()
    } else {
        format!("{name:?}")
    }
}