sim-lib-lang-lua 0.1.4

Lua-style surface profile for the SIM expression runtime.
Documentation
use sim_kernel::{Cx, Error, Expr, Result, Value};
use sim_lib_pattern::{TextLimits, TextMatch, TextOp, run_text_pattern};
use sim_lib_standard_core::Arity;

use crate::{
    LuaEvalPolicy, call::call_lua_value, lua_rawget, lua_table_value, stdlib_string::lua_to_string,
};

pub(crate) enum LuaReplacement {
    Literal(String),
    Table(Value),
    Function(Value),
}

pub(crate) fn replacement_from_value(cx: &mut Cx, value: Value) -> Result<LuaReplacement> {
    match value.object().as_expr(cx)? {
        Expr::String(text) => Ok(LuaReplacement::Literal(text)),
        _ if lua_table_value(&value).is_ok() => Ok(LuaReplacement::Table(value)),
        _ if value.object().as_callable().is_some() => Ok(LuaReplacement::Function(value)),
        _ => Err(Error::Eval(
            "string.gsub replacement must be a string, table, or function".to_owned(),
        )),
    }
}

pub(crate) fn lua_gsub(
    cx: &mut Cx,
    policy: &LuaEvalPolicy,
    subject: &str,
    ops: &[TextOp],
    replacement: &LuaReplacement,
    limit: Option<i64>,
) -> Result<(String, i64)> {
    let mut out = String::new();
    let mut cursor = 0_usize;
    let mut count = 0_i64;
    let max_count = limit.unwrap_or(i64::MAX);
    while cursor <= subject.len() && count < max_count {
        let Some(matched) =
            run_text_pattern(ops, subject, cursor, TextLimits { max_steps: 20_000 })
        else {
            break;
        };
        if matched.start < cursor {
            break;
        }
        out.push_str(&subject[cursor..matched.start]);
        out.push_str(&replacement_text(
            cx,
            policy,
            subject,
            &matched,
            replacement,
        )?);
        count += 1;
        cursor = if matched.end == matched.start {
            next_char_boundary(subject, matched.end)
        } else {
            matched.end
        };
        if cursor > subject.len() {
            break;
        }
    }
    out.push_str(&subject[cursor.min(subject.len())..]);
    Ok((out, count))
}

fn replacement_text(
    cx: &mut Cx,
    policy: &LuaEvalPolicy,
    subject: &str,
    matched: &TextMatch,
    replacement: &LuaReplacement,
) -> Result<String> {
    match replacement {
        LuaReplacement::Literal(text) => expand_literal_replacement(subject, matched, text),
        LuaReplacement::Table(table) => {
            let key = first_capture_or_match(cx, subject, matched)?;
            match lua_rawget(cx, table, &key)? {
                Some(value) if !is_nil(cx, &value)? => {
                    lua_to_string(cx, &value, "gsub table value")
                }
                _ => Ok(match_text(subject, matched).to_owned()),
            }
        }
        LuaReplacement::Function(function) => {
            let values = capture_values(cx, subject, matched)?;
            let returned = call_lua_value(cx, policy, function.clone(), values)?;
            let value = policy
                .kit()
                .adjust_values(returned, Arity::AtLeastOne)
                .into_iter()
                .next()
                .unwrap_or_else(|| policy.kit().nil.clone());
            if is_nil(cx, &value)? || matches!(value.object().as_expr(cx)?, Expr::Bool(false)) {
                Ok(match_text(subject, matched).to_owned())
            } else {
                lua_to_string(cx, &value, "gsub function return")
            }
        }
    }
}

fn expand_literal_replacement(
    subject: &str,
    matched: &TextMatch,
    replacement: &str,
) -> Result<String> {
    let mut out = String::new();
    let mut chars = replacement.chars();
    while let Some(ch) = chars.next() {
        if ch != '%' {
            out.push(ch);
            continue;
        }
        let Some(escape) = chars.next() else {
            return Err(Error::Eval(
                "string.gsub replacement has dangling '%'".to_owned(),
            ));
        };
        match escape {
            '%' => out.push('%'),
            '0' => out.push_str(match_text(subject, matched)),
            '1'..='9' => {
                let index = escape as usize - '1' as usize;
                if let Some((start, end)) = matched.captures.get(index) {
                    out.push_str(subject_range(subject, *start, *end)?);
                } else {
                    return Err(Error::Eval(
                        "string.gsub replacement references a missing capture".to_owned(),
                    ));
                }
            }
            other => {
                out.push('%');
                out.push(other);
            }
        }
    }
    Ok(out)
}

fn capture_values(cx: &mut Cx, subject: &str, matched: &TextMatch) -> Result<Vec<Value>> {
    if matched.captures.is_empty() {
        return cx
            .factory()
            .string(match_text(subject, matched).to_owned())
            .map(|value| vec![value]);
    }
    matched
        .captures
        .iter()
        .map(|(start, end)| {
            cx.factory()
                .string(subject_range(subject, *start, *end)?.to_owned())
        })
        .collect()
}

fn first_capture_or_match(cx: &mut Cx, subject: &str, matched: &TextMatch) -> Result<Value> {
    if let Some((start, end)) = matched.captures.first() {
        return cx
            .factory()
            .string(subject_range(subject, *start, *end)?.to_owned());
    }
    cx.factory().string(match_text(subject, matched).to_owned())
}

fn match_text<'a>(subject: &'a str, matched: &TextMatch) -> &'a str {
    subject_range(subject, matched.start, matched.end).unwrap_or("")
}

fn subject_range(subject: &str, start: usize, end: usize) -> Result<&str> {
    subject
        .get(start..end)
        .ok_or_else(|| Error::Eval("pattern match produced invalid string range".to_owned()))
}

fn next_char_boundary(subject: &str, cursor: usize) -> usize {
    if cursor >= subject.len() {
        return subject.len() + 1;
    }
    let mut next = cursor + 1;
    while next < subject.len() && !subject.is_char_boundary(next) {
        next += 1;
    }
    next
}

fn is_nil(cx: &mut Cx, value: &Value) -> Result<bool> {
    Ok(matches!(value.object().as_expr(cx)?, Expr::Nil))
}