soothfast-sdk 0.4.0

Soothfast SDK engine: native client emitters (Python/TypeScript) over the spec IR
Documentation
//! The TypeScript emitter: interfaces + one client over a static runtime.
//!
//! Wire-faithful by design — emitted interfaces use the property names the
//! API actually sends, so a parsed response *is* the typed value and there
//! is no decode step to drift from the schema.

mod client;
mod models;
pub(crate) mod naming;
mod package;

use std::fmt::Write;

use soothfast_spec::dialect::Info;

use crate::model::{Sdk, Ty};
use crate::{SdkFileSet, SdkOptions};

pub(crate) const HEADER: &str = "// Code generated by `cargo soothfast sdk gen`. DO NOT EDIT.";

const RUNTIME: &str = include_str!("../../assets/typescript/runtime.ts");
const SERVER: &str = include_str!("../../assets/typescript/server.ts");

pub(crate) fn emit(info: &Info, sdk: &Sdk, opts: &SdkOptions) -> Result<SdkFileSet, String> {
    let base_url = crate::base_url_for(info, opts)?;

    let mut out = SdkFileSet {
        notes: sdk.notes.clone(),
        ..SdkFileSet::default()
    };
    let files = &mut out.files;
    files.insert(
        "README.md".into(),
        package::readme(info, sdk, opts, base_url.as_deref()),
    );
    files.insert("package.json".into(), package::package_json(info, opts));
    files.insert("tsconfig.json".into(), package::tsconfig(opts));
    files.insert(
        "src/client.ts".into(),
        client::render(sdk, opts, base_url.as_deref()),
    );
    files.insert("src/index.ts".into(), package::index(info, sdk, opts));
    files.insert("src/models.ts".into(), models::render(sdk, opts));
    files.insert("src/runtime.ts".into(), RUNTIME.to_string());
    if let Some(binary) = &opts.embed {
        files.insert("src/server.ts".into(), SERVER.to_string());
        for target in crate::target::Target::matrix(&opts.targets)? {
            let dir = package::platform_package(opts, target);
            files.insert(
                format!("platforms/{dir}/package.json"),
                package::platform_package_json(opts, target, binary),
            );
        }
    }
    Ok(out)
}

/// Render a type as a TypeScript annotation. `prefix` qualifies named
/// models (`models.` in client code, empty inside `models.ts` itself).
pub(crate) fn annotation(ty: &Ty, prefix: &str) -> String {
    match ty {
        Ty::Str => "string".into(),
        Ty::Int | Ty::Float => "number".into(),
        Ty::Bool => "boolean".into(),
        Ty::Any => "unknown".into(),
        Ty::Unit => "void".into(),
        Ty::List(t) => format!("{}[]", atom(t, prefix)),
        Ty::Map(t) => format!("Record<string, {}>", annotation(t, prefix)),
        Ty::Optional(t) => format!("{} | null", annotation(t, prefix)),
        Ty::Named(n) => format!("{prefix}{}", naming::type_name(n)),
        Ty::Union(arms) => arms
            .iter()
            .map(|a| annotation(a, prefix))
            .collect::<Vec<_>>()
            .join(" | "),
        Ty::Literal(values) => values
            .iter()
            .map(|v| naming::string_lit(v))
            .collect::<Vec<_>>()
            .join(" | "),
    }
}

/// The same annotation, parenthesized when a `[]` suffix would otherwise
/// bind to the last union arm.
fn atom(ty: &Ty, prefix: &str) -> String {
    let rendered = annotation(ty, prefix);
    let needs_parens = match ty {
        Ty::Optional(_) => true,
        Ty::Union(arms) => arms.len() > 1,
        Ty::Literal(values) => values.len() > 1,
        _ => false,
    };
    if needs_parens {
        format!("({rendered})")
    } else {
        rendered
    }
}

/// A field or parameter that may be absent takes `?`; one that is present
/// but nullable keeps its `| null`.
pub(crate) fn member(key: &str, ty: &Ty, required: bool, prefix: &str) -> String {
    let optional = if required { "" } else { "?" };
    format!(
        "{}{optional}: {};",
        naming::prop_key(key),
        annotation(ty, prefix)
    )
}

/// Render a JSDoc comment, one `*` line per paragraph.
pub(crate) fn doc(out: &mut String, indent: &str, lines: &[&str]) {
    let lines: Vec<String> = lines.iter().map(|l| escape(l)).collect();
    match lines.len() {
        0 => {}
        1 => {
            let _ = writeln!(out, "{indent}/** {} */", lines[0]);
        }
        _ => {
            let _ = writeln!(out, "{indent}/**");
            for (i, line) in lines.iter().enumerate() {
                if i > 0 {
                    let _ = writeln!(out, "{indent} *");
                }
                let _ = writeln!(out, "{indent} * {line}");
            }
            let _ = writeln!(out, "{indent} */");
        }
    }
}

/// Flatten a doc string onto one line and defuse a comment terminator.
fn escape(text: &str) -> String {
    text.split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .replace("*/", "*\\/")
}