use symplex::matrix::{CodegenOptions, MathBackend, Matrix, Precision};
use symplex::prelude::*;
use symplex::robotics::DhLink;
use std::fs;
use std::path::{Path, PathBuf};
pub struct CodeGen {
functions: Vec<GeneratedFn>,
options: CodegenOptions,
preamble: Vec<String>,
test_points: Vec<Vec<f64>>,
generate_tests: bool,
}
enum GeneratedFn {
Scalar {
name: String,
expr: Ex,
params: Vec<String>,
},
Matrix {
name: String,
matrix: Matrix,
params: Vec<String>,
},
}
impl CodeGen {
pub fn new() -> Self {
Self {
functions: Vec::new(),
options: CodegenOptions::default(),
preamble: Vec::new(),
test_points: Vec::new(),
generate_tests: false,
}
}
pub fn options(mut self, options: CodegenOptions) -> Self {
self.options = options;
self
}
pub fn no_std(mut self, enabled: bool) -> Self {
if enabled {
self.options.math_backend = MathBackend::CfgGated;
} else {
self.options.math_backend = MathBackend::Std;
}
self
}
pub fn precision_f32(mut self) -> Self {
self.options.precision = Precision::F32;
self
}
pub fn inline(mut self, enabled: bool) -> Self {
self.options.inline = enabled;
self
}
pub fn add_scalar_fn(mut self, name: &str, expr: &Ex, params: &[&str]) -> Self {
self.functions.push(GeneratedFn::Scalar {
name: name.to_string(),
expr: expr.clone(),
params: params.iter().map(|s| s.to_string()).collect(),
});
self
}
pub fn add_matrix_fn(mut self, name: &str, matrix: &Matrix, params: &[&str]) -> Self {
self.functions.push(GeneratedFn::Matrix {
name: name.to_string(),
matrix: matrix.clone(),
params: params.iter().map(|s| s.to_string()).collect(),
});
self
}
pub fn with_tests(mut self, enabled: bool) -> Self {
self.generate_tests = enabled;
self
}
pub fn add_test_point(mut self, point: &[f64]) -> Self {
self.test_points.push(point.to_vec());
self
}
pub fn generate(&self) -> Result<String, Box<dyn std::error::Error>> {
let mut output = String::new();
output.push_str("// Auto-generated by symplex-build. Do not edit.\n\n");
for line in &self.preamble {
output.push_str(line);
output.push('\n');
}
let emit_cfg_module = self.options.math_backend == MathBackend::CfgGated;
if emit_cfg_module {
append_cfg_gated_module(&mut output, self.options.precision);
output.push('\n');
}
let fn_options = CodegenOptions {
emit_runtime: false,
..self.options.clone()
};
let mut functions = String::new();
let mut first_fn = true;
for gfn in &self.functions {
if !first_fn {
functions.push('\n');
}
first_fn = false;
let code = match gfn {
GeneratedFn::Scalar { name, expr, params } => {
let param_refs: Vec<&str> = params.iter().map(|s| s.as_str()).collect();
expr.to_rust_fn_with_options(name, ¶m_refs, &fn_options)?
}
GeneratedFn::Matrix {
name,
matrix,
params,
} => {
let param_refs: Vec<&str> = params.iter().map(|s| s.as_str()).collect();
matrix.to_rust_fn_with_options(name, ¶m_refs, &fn_options)?
}
};
if emit_cfg_module {
let stripped = strip_cfg_gated_module(&code);
functions.push_str(&stripped);
} else {
functions.push_str(&code);
}
functions.push('\n');
}
if self.options.emit_runtime
&& let Some(runtime) = self.options.runtime_module_for(&functions)
{
output.push_str(&runtime);
output.push_str("\n\n");
}
output.push_str(&functions);
if self.generate_tests && !self.test_points.is_empty() {
output.push('\n');
output.push_str("#[cfg(test)]\n");
output.push_str("mod generated_tests {\n");
output.push_str(" use super::*;\n\n");
for (fn_idx, gfn) in self.functions.iter().enumerate() {
let (fn_name, param_count) = match gfn {
GeneratedFn::Scalar { name, params, .. } => (name.as_str(), params.len()),
GeneratedFn::Matrix { name, params, .. } => (name.as_str(), params.len()),
};
for (pt_idx, point) in self.test_points.iter().enumerate() {
if point.len() != param_count {
continue;
}
output.push_str(&format!(
" #[test]\n fn test_{fn_name}_point_{pt_idx}() {{\n"
));
let args: Vec<String> = point
.iter()
.map(|v| {
let float_ty = match self.options.precision {
Precision::F64 => "f64",
Precision::F32 => "f32",
};
format!("{v}_{float_ty}")
})
.collect();
let args_str = args.join(", ");
match &self.functions[fn_idx] {
GeneratedFn::Scalar { .. } => {
output.push_str(&format!(
" let result = {fn_name}({args_str});\n"
));
output.push_str(" assert!(result.is_finite(), \"expected finite result, got {}\", result);\n");
}
GeneratedFn::Matrix { matrix, .. } => {
let total = matrix.nrows() * matrix.ncols();
output.push_str(&format!(
" let result = {fn_name}({args_str});\n"
));
output.push_str(&format!(" for i in 0..{total} {{\n"));
output.push_str(" assert!(result[i].is_finite(), \"entry {} is not finite: {}\", i, result[i]);\n");
output.push_str(" }\n");
}
}
output.push_str(" }\n\n");
}
}
output.push_str("}\n");
}
Ok(output)
}
pub fn write_to_out_dir(&self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
let out_dir = std::env::var("OUT_DIR")
.map_err(|_| "OUT_DIR not set — this function must be called from a build script")?;
let path = PathBuf::from(out_dir).join(filename);
let code = self.generate()?;
fs::write(&path, code)?;
println!("cargo:rerun-if-changed=build.rs");
Ok(())
}
pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
let code = self.generate()?;
if let Some(parent) = path.as_ref().parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, code)?;
Ok(())
}
}
impl Default for CodeGen {
fn default() -> Self {
Self::new()
}
}
fn append_cfg_gated_module(out: &mut String, precision: Precision) {
let ft = match precision {
Precision::F64 => "f64",
Precision::F32 => "f32",
};
let funcs = [
"sin", "cos", "tan", "exp", "ln", "abs", "sqrt", "cbrt", "asin", "acos", "atan", "sinh",
"cosh", "tanh", "asinh", "acosh", "atanh", "floor", "ceil", "signum",
];
out.push_str("#[cfg(feature = \"std\")]\n");
out.push_str("mod math {\n");
for func in &funcs {
out.push_str(&format!(
" #[inline] pub fn {func}(x: {ft}) -> {ft} {{ x.{func}() }}\n"
));
}
out.push_str(&format!(
" #[inline] pub fn atan2(y: {ft}, x: {ft}) -> {ft} {{ y.atan2(x) }}\n"
));
out.push_str(&format!(
" #[inline] pub fn powf(base: {ft}, exp: {ft}) -> {ft} {{ base.powf(exp) }}\n"
));
out.push_str(&format!(
" #[inline] pub fn powi(base: {ft}, exp: i32) -> {ft} {{ base.powi(exp) }}\n"
));
out.push_str(&format!(
" #[inline] pub fn min(a: {ft}, b: {ft}) -> {ft} {{ a.min(b) }}\n"
));
out.push_str(&format!(
" #[inline] pub fn max(a: {ft}, b: {ft}) -> {ft} {{ a.max(b) }}\n"
));
out.push_str(&format!(
" #[inline] pub fn expm1(x: {ft}) -> {ft} {{ x.exp_m1() }}\n"
));
out.push_str(&format!(
" #[inline] pub fn log1p(x: {ft}) -> {ft} {{ x.ln_1p() }}\n"
));
out.push_str(&format!(
" #[inline] pub fn log2(x: {ft}) -> {ft} {{ x.log2() }}\n"
));
out.push_str(&format!(
" #[inline] pub fn exp2(x: {ft}) -> {ft} {{ x.exp2() }}\n"
));
out.push_str(&format!(
" #[inline] pub fn fma(a: {ft}, b: {ft}, c: {ft}) -> {ft} {{ a.mul_add(b, c) }}\n"
));
out.push_str(&format!(
" #[inline] pub fn sin_cos(x: {ft}) -> ({ft}, {ft}) {{ x.sin_cos() }}\n"
));
out.push_str("}\n\n");
out.push_str("#[cfg(not(feature = \"std\"))]\n");
out.push_str("mod math {\n");
let libm_funcs = [
"sin", "cos", "tan", "exp", "sqrt", "cbrt", "asin", "acos", "atan", "sinh", "cosh", "tanh",
"asinh", "acosh", "atanh", "floor", "ceil",
];
for func in &libm_funcs {
out.push_str(&format!(
" #[inline] pub fn {func}(x: {ft}) -> {ft} {{ libm::{func}(x as f64) as {ft} }}\n"
));
}
out.push_str(&format!(
" #[inline] pub fn abs(x: {ft}) -> {ft} {{ libm::fabs(x as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn ln(x: {ft}) -> {ft} {{ libm::log(x as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn signum(x: {ft}) -> {ft} {{ if x > 0.0 {{ 1.0 }} else if x < 0.0 {{ -1.0 }} else {{ 0.0 }} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn atan2(y: {ft}, x: {ft}) -> {ft} {{ libm::atan2(y as f64, x as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn powf(base: {ft}, exp: {ft}) -> {ft} {{ libm::pow(base as f64, exp as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn powi(base: {ft}, exp: i32) -> {ft} {{ libm::pow(base as f64, exp as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn min(a: {ft}, b: {ft}) -> {ft} {{ libm::fmin(a as f64, b as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn max(a: {ft}, b: {ft}) -> {ft} {{ libm::fmax(a as f64, b as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn expm1(x: {ft}) -> {ft} {{ libm::expm1(x as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn log1p(x: {ft}) -> {ft} {{ libm::log1p(x as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn log2(x: {ft}) -> {ft} {{ libm::log2(x as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn exp2(x: {ft}) -> {ft} {{ libm::exp2(x as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn fma(a: {ft}, b: {ft}, c: {ft}) -> {ft} {{ libm::fma(a as f64, b as f64, c as f64) as {ft} }}\n"
));
out.push_str(&format!(
" #[inline] pub fn sin_cos(x: {ft}) -> ({ft}, {ft}) {{ (libm::sin(x as f64) as {ft}, libm::cos(x as f64) as {ft}) }}\n"
));
out.push_str("}\n");
}
fn strip_cfg_gated_module(code: &str) -> String {
let mut result = String::new();
let mut lines = code.lines().peekable();
while let Some(line) = lines.next() {
if line.starts_with("#[cfg(") && line.contains("feature") {
if let Some(&next) = lines.peek()
&& next.starts_with("mod math {")
{
lines.next();
let mut brace_depth = 1;
while brace_depth > 0 {
if let Some(inner) = lines.next() {
for ch in inner.chars() {
if ch == '{' {
brace_depth += 1;
} else if ch == '}' {
brace_depth -= 1;
}
}
} else {
break;
}
}
if let Some(&next_after) = lines.peek()
&& next_after.trim().is_empty()
{
lines.next();
}
continue;
}
}
result.push_str(line);
result.push('\n');
}
let trimmed = result.trim_start_matches('\n');
trimmed.to_string()
}
#[derive(serde::Deserialize)]
struct RobotConfig {
#[allow(dead_code)]
robot: RobotInfo,
joints: Vec<JointConfig>,
generate: GenerateConfig,
}
#[derive(serde::Deserialize)]
struct RobotInfo {
#[allow(dead_code)]
name: String,
}
#[derive(serde::Deserialize)]
struct JointConfig {
theta: String,
#[serde(default)]
d: f64,
#[serde(default)]
a: f64,
#[serde(default)]
alpha: f64,
}
fn default_functions() -> Vec<String> {
vec!["fk".to_string(), "jacobian".to_string()]
}
fn default_output() -> String {
"robot_math.rs".to_string()
}
#[derive(serde::Deserialize)]
struct GenerateConfig {
#[serde(default = "default_functions")]
functions: Vec<String>,
#[serde(default = "default_output")]
#[allow(dead_code)]
output: String,
}
pub fn from_toml(path: impl AsRef<Path>) -> Result<CodeGen, Box<dyn std::error::Error>> {
let content = fs::read_to_string(path.as_ref())?;
let config: RobotConfig = toml::from_str(&content)?;
let ctx = Context::new();
let theta_vars: Vec<Ex> = config.joints.iter().map(|j| ctx.symbol(&j.theta)).collect();
let d_vals: Vec<Ex> = config
.joints
.iter()
.map(|j| float_to_expr(&ctx, j.d))
.collect();
let a_vals: Vec<Ex> = config
.joints
.iter()
.map(|j| float_to_expr(&ctx, j.a))
.collect();
let alpha_vals: Vec<Ex> = config
.joints
.iter()
.map(|j| float_to_expr(&ctx, j.alpha))
.collect();
let dh_params: Vec<DhLink<'_>> = theta_vars
.iter()
.enumerate()
.map(|(i, theta)| DhLink {
theta,
d: &d_vals[i],
a: &a_vals[i],
alpha: &alpha_vals[i],
})
.collect();
let theta_names: Vec<&str> = config.joints.iter().map(|j| j.theta.as_str()).collect();
let mut codegen = CodeGen::new();
for func in &config.generate.functions {
match func.as_str() {
"fk" => {
let (x, y, z) = symplex::robotics::fk_position(&dh_params);
codegen = codegen.add_scalar_fn("fk_x", &x, &theta_names);
codegen = codegen.add_scalar_fn("fk_y", &y, &theta_names);
codegen = codegen.add_scalar_fn("fk_z", &z, &theta_names);
}
"jacobian" => {
let (x, y, _z) = symplex::robotics::fk_position(&dh_params);
let theta_refs: Vec<&Ex> = theta_vars.iter().collect();
let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs);
codegen = codegen.add_matrix_fn("jacobian", &j, &theta_names);
}
"fk_matrix" => {
let t = symplex::robotics::fk_chain(&dh_params);
codegen = codegen.add_matrix_fn("fk_matrix", &t, &theta_names);
}
other => {
return Err(format!("unknown generate function: {other}").into());
}
}
}
Ok(codegen)
}
fn float_to_expr(ctx: &Context, v: f64) -> Ex {
ctx.from_f64_approx(v, 1_000_000)
.unwrap_or_else(|e| panic!("symplex-build: invalid DH parameter {v}: {e}"))
}
pub fn robot_arm(joints: &[(&str, f64, f64, f64)]) -> RobotArmBuilder {
let owned: Vec<(String, f64, f64, f64)> = joints
.iter()
.map(|(name, d, a, alpha)| (name.to_string(), *d, *a, *alpha))
.collect();
RobotArmBuilder::new(owned)
}
pub struct RobotArmBuilder {
ctx: Context,
joints: Vec<(String, f64, f64, f64)>,
codegen: CodeGen,
generated_fk: bool,
generated_jacobian: bool,
}
impl RobotArmBuilder {
fn new(joints: Vec<(String, f64, f64, f64)>) -> Self {
Self {
ctx: Context::new(),
joints,
codegen: CodeGen::new(),
generated_fk: false,
generated_jacobian: false,
}
}
fn build_dh(&self) -> (Vec<Ex>, Vec<Ex>, Vec<Ex>, Vec<Ex>) {
let ctx = &self.ctx;
let thetas: Vec<Ex> = self
.joints
.iter()
.map(|(name, _, _, _)| ctx.symbol(name))
.collect();
let d_vals: Vec<Ex> = self
.joints
.iter()
.map(|(_, d, _, _)| float_to_expr(ctx, *d))
.collect();
let a_vals: Vec<Ex> = self
.joints
.iter()
.map(|(_, _, a, _)| float_to_expr(ctx, *a))
.collect();
let alpha_vals: Vec<Ex> = self
.joints
.iter()
.map(|(_, _, _, alpha)| float_to_expr(ctx, *alpha))
.collect();
(thetas, d_vals, a_vals, alpha_vals)
}
fn theta_names_owned(&self) -> Vec<String> {
self.joints
.iter()
.map(|(name, _, _, _)| name.clone())
.collect()
}
pub fn generate_fk(mut self, name: &str) -> Self {
let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
let dh: Vec<DhLink<'_>> = thetas
.iter()
.enumerate()
.map(|(i, t)| DhLink {
theta: t,
d: &d_vals[i],
a: &a_vals[i],
alpha: &alpha_vals[i],
})
.collect();
let owned_names = self.theta_names_owned();
let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
let (x, y, z) = symplex::robotics::fk_position(&dh);
let name_x = format!("{name}_x");
let name_y = format!("{name}_y");
let name_z = format!("{name}_z");
self.codegen = self.codegen.add_scalar_fn(&name_x, &x, &theta_names);
self.codegen = self.codegen.add_scalar_fn(&name_y, &y, &theta_names);
self.codegen = self.codegen.add_scalar_fn(&name_z, &z, &theta_names);
self.generated_fk = true;
self
}
pub fn generate_fk_matrix(mut self, name: &str) -> Self {
let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
let dh: Vec<DhLink<'_>> = thetas
.iter()
.enumerate()
.map(|(i, t)| DhLink {
theta: t,
d: &d_vals[i],
a: &a_vals[i],
alpha: &alpha_vals[i],
})
.collect();
let owned_names = self.theta_names_owned();
let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
let t = symplex::robotics::fk_chain(&dh);
self.codegen = self.codegen.add_matrix_fn(name, &t, &theta_names);
self
}
pub fn generate_jacobian(mut self, name: &str) -> Self {
let (thetas, d_vals, a_vals, alpha_vals) = self.build_dh();
let dh: Vec<DhLink<'_>> = thetas
.iter()
.enumerate()
.map(|(i, t)| DhLink {
theta: t,
d: &d_vals[i],
a: &a_vals[i],
alpha: &alpha_vals[i],
})
.collect();
let owned_names = self.theta_names_owned();
let theta_names: Vec<&str> = owned_names.iter().map(|s| s.as_str()).collect();
let (x, y, _z) = symplex::robotics::fk_position(&dh);
let theta_refs: Vec<&Ex> = thetas.iter().collect();
let j = symplex::matrix::jacobian(&[&x, &y], &theta_refs);
self.codegen = self.codegen.add_matrix_fn(name, &j, &theta_names);
self.generated_jacobian = true;
self
}
pub fn generate_all(self) -> Self {
let s = if !self.generated_fk {
self.generate_fk("fk")
} else {
self
};
if !s.generated_jacobian {
s.generate_jacobian("jacobian")
} else {
s
}
}
pub fn no_std(mut self) -> Self {
self.codegen = self.codegen.no_std(true);
self
}
pub fn write_to_out_dir(self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
self.codegen.write_to_out_dir(filename)
}
pub fn write_to_path(self, path: impl AsRef<Path>) -> Result<(), Box<dyn std::error::Error>> {
self.codegen.write_to_path(path)
}
pub fn into_codegen(self) -> CodeGen {
self.codegen
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codegen_new_default() {
let cg = CodeGen::new();
assert!(cg.functions.is_empty());
assert!(!cg.generate_tests);
}
#[test]
fn codegen_default_trait() {
let cg = CodeGen::default();
assert!(cg.functions.is_empty());
}
#[test]
fn codegen_generate_empty() {
let cg = CodeGen::new();
let code = cg.generate().unwrap();
assert!(
code.contains("Auto-generated by symplex-build"),
"expected header comment in generated output, got: {code}"
);
assert!(
!code.contains("fn "),
"expected no function definitions in empty codegen"
);
}
#[test]
fn float_to_expr_is_exact_and_reduced() {
let ctx = Context::new();
assert_eq!(format!("{}", float_to_expr(&ctx, 0.0)), "0");
assert_eq!(format!("{}", float_to_expr(&ctx, 2.0)), "2");
assert_eq!(format!("{}", float_to_expr(&ctx, -3.0)), "-3");
assert_eq!(format!("{}", float_to_expr(&ctx, 0.3)), "3/10");
assert_eq!(format!("{}", float_to_expr(&ctx, 0.25)), "1/4");
assert_eq!(format!("{}", float_to_expr(&ctx, 0.1 + 0.2)), "3/10");
assert_eq!(format!("{}", float_to_expr(&ctx, 1.0 / 3.0)), "1/3");
assert_eq!(format!("{}", float_to_expr(&ctx, 0.123456)), "1929/15625");
}
#[test]
fn robot_arm_generates_fk_matrix() {
let code = robot_arm(&[("q1", 0.0, 0.3, 0.0), ("q2", 0.1, 0.25, 0.0)])
.generate_fk_matrix("fk_t")
.into_codegen()
.generate()
.unwrap();
assert!(code.contains("fn fk_t("), "{code}");
assert!(
code.contains("q1: f64") && code.contains("q2: f64"),
"{code}"
);
assert!(code.contains("[f64; 16]"), "expected 4×4 matrix:\n{code}");
assert!(!code.contains("0.30000000000000004"), "{code}");
}
#[test]
fn fk_matrix_last_column_matches_fk_position() {
let ctx = Context::new();
let (q1, q2) = (ctx.symbol("q1"), ctx.symbol("q2"));
let zero = ctx.int(0);
let (l1, l2) = (float_to_expr(&ctx, 0.3), float_to_expr(&ctx, 0.25));
let dh = [
DhLink {
theta: &q1,
d: &zero,
a: &l1,
alpha: &zero,
},
DhLink {
theta: &q2,
d: &zero,
a: &l2,
alpha: &zero,
},
];
let t = symplex::robotics::fk_chain(&dh);
let (x, y, z) = symplex::robotics::fk_position(&dh);
assert_eq!(t.get(0, 3).eval(), x);
assert_eq!(t.get(1, 3).eval(), y);
assert_eq!(t.get(2, 3).eval(), z);
}
#[test]
fn from_toml_accepts_fk_matrix() {
let dir = std::env::temp_dir().join(format!("symplex_build_fkm_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("robot.toml");
fs::write(
&path,
r#"
[robot]
name = "one_link"
[[joints]]
theta = "q"
a = 0.5
[generate]
functions = ["fk_matrix"]
"#,
)
.unwrap();
let code = from_toml(&path).unwrap().generate().unwrap();
assert!(code.contains("fn fk_matrix("), "{code}");
assert!(code.contains("[f64; 16]"), "{code}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn robot_arm_generates_fk_and_jacobian() {
let code = robot_arm(&[("theta1", 0.0, 0.3, 0.0), ("theta2", 0.0, 0.25, 0.0)])
.generate_all()
.into_codegen()
.generate()
.unwrap();
for name in ["fk_x", "fk_y", "fk_z", "jacobian"] {
assert!(
code.contains(&format!("fn {name}(")),
"expected `{name}` in generated code:\n{code}"
);
}
assert!(code.contains("theta1: f64") && code.contains("theta2: f64"));
assert!(code.contains("[f64; 4]"), "expected 2×2 Jacobian:\n{code}");
}
#[test]
fn robot_arm_no_std_emits_single_math_module() {
let code = robot_arm(&[("q", 0.0, 1.0, 0.0)])
.no_std()
.generate_all()
.into_codegen()
.generate()
.unwrap();
assert_eq!(code.matches("mod math {").count(), 2, "{code}");
}
fn two_special_fns(opts: CodegenOptions) -> CodeGen {
let ctx = Context::new();
let x = ctx.symbol("x");
let y = ctx.symbol("y");
CodeGen::new()
.options(opts)
.add_scalar_fn("g", &(x.gamma() + &y), &["x", "y"])
.add_scalar_fn("e", &(x.erf() * &y), &["x", "y"])
}
#[test]
fn generate_emits_runtime_module_once_for_two_special_functions() {
let code = two_special_fns(CodegenOptions::default())
.generate()
.unwrap();
assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
assert!(
code.contains("pub fn gamma(") && code.contains("pub fn erf("),
"{code}"
);
assert!(code.contains("fn g(") && code.contains("fn e("), "{code}");
assert!(code.find("mod symplex_rt {").unwrap() < code.find("fn g(").unwrap());
assert!(!code.contains("pub fn bessel_k("), "{code}");
}
#[test]
fn generate_emits_runtime_module_once_in_no_std_mode() {
let code = two_special_fns(CodegenOptions::no_std())
.generate()
.unwrap();
assert_eq!(code.matches("mod symplex_rt {").count(), 1, "{code}");
assert_eq!(code.matches("mod math {").count(), 2, "{code}");
let math_pos = code.find("mod math {").unwrap();
let rt_pos = code.find("mod symplex_rt {").unwrap();
let fn_pos = code.find("fn g(").unwrap();
assert!(math_pos < rt_pos && rt_pos < fn_pos, "{code}");
}
#[test]
fn generate_honours_emit_runtime_false() {
let opts = CodegenOptions {
emit_runtime: false,
..Default::default()
};
let code = two_special_fns(opts).generate().unwrap();
assert_eq!(code.matches("mod symplex_rt {").count(), 0, "{code}");
assert!(code.contains("symplex_rt::gamma("), "{code}");
}
#[test]
fn generate_omits_runtime_when_unused() {
let ctx = Context::new();
let x = ctx.symbol("x");
let code = CodeGen::new()
.add_scalar_fn("f", &(x.sin() + x.powi(2)), &["x"])
.generate()
.unwrap();
assert!(!code.contains("mod symplex_rt"), "{code}");
}
#[test]
fn generated_file_with_two_special_functions_compiles() {
let Ok(out) = std::process::Command::new("rustc")
.arg("--version")
.output()
else {
eprintln!("rustc not available; skipping compile check");
return;
};
if !out.status.success() {
return;
}
let code = two_special_fns(CodegenOptions::default())
.generate()
.unwrap();
let dir = std::env::temp_dir().join(format!("symplex_build_rt_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let src = dir.join("gen.rs");
fs::write(&src, format!("#![allow(dead_code)]\n{code}")).unwrap();
let out = std::process::Command::new("rustc")
.args(["--crate-type", "lib", "--edition", "2024", "-o"])
.arg(dir.join("gen.rlib"))
.arg(&src)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let _ = fs::remove_dir_all(&dir);
assert!(
out.status.success(),
"generated file failed to compile:\n{stderr}\n{code}"
);
}
#[test]
fn from_toml_round_trip() {
let dir = std::env::temp_dir().join(format!("symplex_build_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("robot.toml");
fs::write(
&path,
r#"
[robot]
name = "two_link"
[[joints]]
theta = "theta1"
a = 0.3
[[joints]]
theta = "theta2"
a = 0.25
[generate]
functions = ["fk", "jacobian"]
"#,
)
.unwrap();
let code = from_toml(&path).unwrap().generate().unwrap();
assert!(code.contains("fn fk_x("), "{code}");
assert!(code.contains("fn jacobian("), "{code}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn from_toml_rejects_unknown_function() {
let dir = std::env::temp_dir().join(format!("symplex_build_bad_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("robot.toml");
fs::write(
&path,
r#"
[robot]
name = "r"
[[joints]]
theta = "q"
[generate]
functions = ["dynamics"]
"#,
)
.unwrap();
let err = from_toml(&path)
.err()
.expect("unknown function should error");
assert!(err.to_string().contains("dynamics"), "{err}");
let _ = fs::remove_dir_all(&dir);
}
}