use crate::ident::{to_const_ident, validate_rust_path};
use crate::model::{Haptic, Inst, LoopMode, Motor, Profile, Rounding};
use crate::parser::MAX_INSTRUCTIONS;
use crate::semantic::{profile_for_haptic, profile_map, resolve_curve_symbol};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
#[derive(Copy, Clone, Debug)]
pub(crate) struct CodegenOptions<'a> {
pub(crate) curves_module: &'a str,
pub(crate) haptics_crate: &'a str,
}
pub(crate) fn generate_rust(
options: &CodegenOptions<'_>,
profiles: &[Profile],
haptics: &[Haptic],
) -> Result<String, String> {
validate_rust_path(options.curves_module, "--curves-module")?;
validate_rust_path(options.haptics_crate, "--haptics-crate")?;
for haptic in haptics {
if haptic.instructions.len() > MAX_INSTRUCTIONS {
return Err(format!(
"haptic `{}`: instruction count {} exceeds limit of {MAX_INSTRUCTIONS}",
haptic.name,
haptic.instructions.len()
));
}
}
let mut out = String::new();
writeln!(&mut out, "// @generated by ph-haptics-gen").ok();
writeln!(&mut out, "// do not edit by hand").ok();
writeln!(&mut out).ok();
writeln!(
&mut out,
"use {}::{{CompiledCatalog, CompiledHapticDef, DEFAULT_ERM_PROFILE, DEFAULT_LRA_PROFILE, Instruction, LoopMode, MotorKind, MotorProfile, Program, Ramp}};",
options.haptics_crate
)
.ok();
writeln!(&mut out, "use ph_curves::MonotonicCurveLut256;").ok();
writeln!(&mut out, "use ph_curves::Rounding;").ok();
writeln!(&mut out, "use {}::*;", options.curves_module).ok();
writeln!(&mut out).ok();
let profile_map = profile_map(profiles);
let default_erm = Profile::default_erm_named("__default_erm");
let default_lra = Profile::default_lra_named("__default_lra");
let mut emitted_profile_names = BTreeSet::new();
let mut resolved_haptics = Vec::with_capacity(haptics.len());
for haptic in haptics {
let profile = profile_for_haptic(haptic, &profile_map, &default_erm, &default_lra)?;
if haptic.profile.is_some()
&& let Some(profile) = profile
{
emitted_profile_names.insert(profile.name.as_str());
}
resolved_haptics.push((haptic, profile));
}
ensure_unique_symbols(profiles, &emitted_profile_names, haptics)?;
for profile in profiles {
if !emitted_profile_names.contains(profile.name.as_str()) {
continue;
}
writeln!(
&mut out,
"/// Generated motor profile for `{}`.",
profile.name
)
.ok();
writeln!(
&mut out,
"pub static {}: MotorProfile = MotorProfile::new({}, {}, {}, {}, {}, {}, {}, {});",
profile_const_name(&profile.name),
profile.kick_ms,
Profile::percent_to_frac_u8(profile.kick_level),
Profile::percent_to_frac_u8(profile.min_level),
Profile::percent_to_frac_u8(profile.max_level),
match &profile.gamma {
Some(curve) => format!("Some(&{})", to_const_ident(curve)),
None => "None".to_owned(),
},
profile.ramp_step_ms,
profile.min_dt_ms,
Profile::percent_to_frac_u8(profile.duty_step),
)
.ok();
}
if !emitted_profile_names.is_empty() {
writeln!(&mut out).ok();
}
let mut compiled_names = Vec::new();
for (haptic, profile) in resolved_haptics {
let profile_expr = profile_expr(haptic, profile);
let h_name = haptic_const_name(&haptic.name);
let i_name = format!("{h_name}_INSTRUCTIONS");
let p_name = format!("{h_name}_PROGRAM");
let mut rendered = Vec::new();
for inst in &haptic.instructions {
rendered.push(render_instruction(inst, haptic, profile)?);
}
writeln!(
&mut out,
"/// Generated instruction sequence for `{}`.",
haptic.name
)
.ok();
writeln!(
&mut out,
"pub static {}: [Instruction<MonotonicCurveLut256>; {}] = [",
i_name,
rendered.len()
)
.ok();
for line in rendered {
writeln!(&mut out, " {},", line).ok();
}
writeln!(&mut out, "];").ok();
writeln!(&mut out, "/// Generated program for `{}`.", haptic.name).ok();
writeln!(
&mut out,
"pub const {}: Program<'static, MonotonicCurveLut256> = Program::new(MotorKind::{}, &{}).with_loop_mode(LoopMode::{});",
p_name,
match haptic.motor {
Motor::Erm => "Erm",
Motor::Lra => "Lra",
},
i_name,
match haptic.loop_mode {
LoopMode::Once => "Once".to_owned(),
LoopMode::Forever => "Forever".to_owned(),
LoopMode::Count(n) => format!("Count({n})"),
}
)
.ok();
writeln!(
&mut out,
"/// Generated compiled haptic definition for `{}`.",
haptic.name
)
.ok();
writeln!(
&mut out,
"pub const {}: CompiledHapticDef<'static> = CompiledHapticDef::new(\"{}\", {}, {});",
h_name, haptic.name, p_name, profile_expr,
)
.ok();
writeln!(&mut out).ok();
compiled_names.push(h_name);
}
writeln!(&mut out, "/// All generated compiled haptic definitions.").ok();
writeln!(
&mut out,
"pub const COMPILED_HAPTICS: [CompiledHapticDef<'static>; {}] = [",
compiled_names.len()
)
.ok();
for name in &compiled_names {
writeln!(&mut out, " {},", name).ok();
}
writeln!(&mut out, "];").ok();
writeln!(
&mut out,
"/// Generated catalog for name lookup and runner construction."
)
.ok();
writeln!(
&mut out,
"pub const COMPILED_CATALOG: CompiledCatalog<'static> = CompiledCatalog::new(&COMPILED_HAPTICS);"
)
.ok();
Ok(out)
}
fn ensure_unique_symbols(
profiles: &[Profile],
emitted_profile_names: &BTreeSet<&str>,
haptics: &[Haptic],
) -> Result<(), String> {
let mut owners: BTreeMap<String, String> = BTreeMap::new();
let mut claim = |symbol: String, owner: String| -> Result<(), String> {
if let Some(existing) = owners.get(&symbol) {
return Err(format!(
"generated symbol `{symbol}` is claimed by both {existing} and {owner}; rename one of them"
));
}
owners.insert(symbol, owner);
Ok(())
};
for profile in profiles {
if !emitted_profile_names.contains(profile.name.as_str()) {
continue;
}
let owner = format!("profile `{}`", profile.name);
claim(profile_const_name(&profile.name), owner)?;
}
for haptic in haptics {
let base = haptic_const_name(&haptic.name);
let owner = format!("haptic `{}`", haptic.name);
claim(format!("{base}_INSTRUCTIONS"), owner.clone())?;
claim(format!("{base}_PROGRAM"), owner.clone())?;
claim(base, owner)?;
}
Ok(())
}
fn profile_expr(haptic: &Haptic, profile: Option<&Profile>) -> String {
match (haptic.profile.is_some(), profile) {
(true, Some(profile)) => format!("Some(&{})", profile_const_name(&profile.name)),
(false, Some(_)) => match haptic.motor {
Motor::Erm => "Some(&DEFAULT_ERM_PROFILE)".to_owned(),
Motor::Lra => "Some(&DEFAULT_LRA_PROFILE)".to_owned(),
},
(_, None) => "None".to_owned(),
}
}
fn render_instruction(
inst: &Inst,
haptic: &Haptic,
profile: Option<&Profile>,
) -> Result<String, String> {
match inst {
Inst::Ramp {
line,
duration_ms,
from,
to,
curve,
step,
rounding,
min_dt_ms,
lra_hz,
lra_hz_to,
} => {
let curve_symbol = resolve_curve_symbol(curve, profile, *line, &haptic.name)?;
let from = clamp_level(*from, profile);
let to = clamp_level(*to, profile);
let step = match step {
Some(value) => *value,
None => profile_step(profile).unwrap_or(1),
}
.max(1);
let min_level = profile_min_level(profile);
let from = step_align_above_min(from, step, min_level);
let to = step_align_above_min(to, step, min_level);
let rounding = match rounding.unwrap_or(Rounding::Nearest) {
Rounding::Nearest => "Nearest",
Rounding::Floor => "Floor",
Rounding::Ceil => "Ceil",
};
let min_dt_ms = match min_dt_ms {
Some(value) => *value,
None => profile_min_dt(profile).unwrap_or(0),
};
let lra_hz = option_u16_expr(*lra_hz);
let lra_hz_to = option_u16_expr(*lra_hz_to);
Ok(format!(
"Instruction::Ramp(Ramp {{ duration_ms: {}, from: {}, to: {}, curve: {}, step: {}, rounding: Rounding::{}, min_dt_ms: {}, lra_frequency_hz: {}, lra_frequency_hz_to: {} }})",
duration_ms, from, to, curve_symbol, step, rounding, min_dt_ms, lra_hz, lra_hz_to
))
}
Inst::Hold {
duration_ms,
level,
lra_hz,
..
} => Ok(format!(
"Instruction::Hold {{ duration_ms: {}, level: {}, lra_frequency_hz: {} }}",
duration_ms,
clamp_level(*level, profile),
option_u16_expr(*lra_hz)
)),
Inst::Pause { duration_ms, .. } => Ok(format!(
"Instruction::Pause {{ duration_ms: {} }}",
duration_ms
)),
}
}
fn profile_const_name(name: &str) -> String {
format!("PROFILE_{}", to_const_ident(name))
}
fn haptic_const_name(name: &str) -> String {
format!("HAPTIC_{}", to_const_ident(name))
}
fn clamp_level(level: u16, profile: Option<&Profile>) -> u16 {
let Some(profile) = profile else {
return level;
};
if level == 0 {
return 0;
}
let min_frac = u32::from(Profile::percent_to_frac_u8(profile.min_level));
let max_frac = u32::from(Profile::percent_to_frac_u8(profile.max_level));
let mut frac = ((u32::from(level) * 255) + (u32::from(u16::MAX) / 2)) / u32::from(u16::MAX);
if frac < min_frac {
frac = min_frac;
}
if frac > max_frac {
frac = max_frac;
}
(((frac * u32::from(u16::MAX)) + 127) / 255) as u16
}
fn profile_min_level(profile: Option<&Profile>) -> u16 {
profile
.map(|p| {
let frac = u32::from(Profile::percent_to_frac_u8(p.min_level));
((frac * u32::from(u16::MAX) + 127) / 255) as u16
})
.unwrap_or(0)
}
fn step_align_above_min(level: u16, step: u16, min_level: u16) -> u16 {
if level == 0 || step <= 1 || min_level == 0 {
return level;
}
let step32 = u32::from(step);
let floored = (u32::from(level) / step32) * step32;
if floored >= u32::from(min_level) {
return level;
}
let ceiled = floored.saturating_add(step32);
ceiled.min(u32::from(u16::MAX)) as u16
}
fn profile_step(profile: Option<&Profile>) -> Option<u16> {
profile.map(|profile| {
let frac = u32::from(Profile::percent_to_frac_u8(profile.duty_step));
let step = (frac * u32::from(u16::MAX) + 127) / 255;
step.max(1) as u16
})
}
fn profile_min_dt(profile: Option<&Profile>) -> Option<u32> {
profile.map(|profile| {
if profile.min_dt_ms > 0 {
u32::from(profile.min_dt_ms)
} else {
u32::from(profile.ramp_step_ms)
}
})
}
fn option_u16_expr(value: Option<u16>) -> String {
match value {
Some(value) => format!("Some({value})"),
None => "None".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_haptics_document;
#[test]
fn generator_rejects_invalid_module_paths() {
let src = "\
haptic click motor=erm
hold 12 68.68
end
";
let haptics = parse_haptics_document(src).unwrap();
let err = generate_rust(
&CodegenOptions {
curves_module: "crate/curves",
haptics_crate: "ph_haptics",
},
&[],
&haptics,
)
.unwrap_err();
assert!(err.contains("--curves-module"), "error: {err}");
}
#[test]
fn generator_uses_default_erm_profile_when_missing() {
let src = "\
haptic click motor=erm
hold 12 68.68
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let output = generate_rust(&options, &[], &haptics).unwrap();
assert!(output.contains("Some(&DEFAULT_ERM_PROFILE)"));
assert!(output.contains("duration_ms: 12"), "output:\n{output}");
assert!(output.contains("/// Generated instruction sequence for `click`."));
assert!(output.contains("/// Generated program for `click`."));
assert!(output.contains("/// Generated compiled haptic definition for `click`."));
assert!(output.contains("/// All generated compiled haptic definitions."));
assert!(output.contains("/// Generated catalog for name lookup"));
assert!(output.contains("pub const COMPILED_CATALOG: CompiledCatalog<'static>"));
}
#[test]
fn generator_emits_only_referenced_profiles() {
let src = "\
haptic click motor=erm profile=used
hold 12 68.68
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let output = generate_rust(
&options,
&[
Profile::default_erm_named("used"),
Profile::default_erm_named("unused"),
],
&haptics,
)
.unwrap();
assert!(output.contains("/// Generated motor profile for `used`."));
assert!(output.contains("pub static PROFILE_USED: MotorProfile"));
assert!(!output.contains("pub static PROFILE_UNUSED: MotorProfile"));
assert!(output.contains("Some(&PROFILE_USED)"));
}
#[test]
fn generator_renders_ramp_with_profile_defaults() {
let src = "\
haptic sweep motor=erm profile=my_profile
ramp 50 0 100 linear
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let mut profile = Profile::default_erm_named("my_profile");
profile.duty_step = 10.0 / 255.0 * 100.0; profile.min_dt_ms = 3;
let output = generate_rust(&options, &[profile], &haptics).unwrap();
assert!(output.contains("step: 2570"), "output:\n{output}");
assert!(output.contains("min_dt_ms: 3"), "output:\n{output}");
assert!(output.contains("curve: LINEAR"), "output:\n{output}");
assert!(
output.contains("Instruction::Ramp(Ramp"),
"output:\n{output}"
);
}
#[test]
fn generator_renders_pause_instruction() {
let src = "\
haptic tap motor=erm
hold 5 76.3
pause 20
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let output = generate_rust(&options, &[], &haptics).unwrap();
assert!(
output.contains("Instruction::Pause { duration_ms: 20 }"),
"output:\n{output}"
);
}
#[test]
fn generator_renders_lra_with_forever_loop() {
let src = "\
haptic buzz motor=lra loop=forever
hold 20 76.3 lra_hz=210
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let output = generate_rust(&options, &[], &haptics).unwrap();
assert!(output.contains("MotorKind::Lra"), "output:\n{output}");
assert!(output.contains("LoopMode::Forever"), "output:\n{output}");
assert!(
output.contains("lra_frequency_hz: Some(210)"),
"output:\n{output}"
);
assert!(
output.contains("Some(&DEFAULT_LRA_PROFILE)"),
"output:\n{output}"
);
}
#[test]
fn generator_renders_gamma_curve_in_profile() {
let src = "\
haptic click motor=erm profile=gamma_prof
hold 12 68.68
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let mut profile = Profile::default_erm_named("gamma_prof");
profile.gamma = Some("ease_in_quad".to_owned());
let output = generate_rust(&options, &[profile], &haptics).unwrap();
assert!(output.contains("Some(&EASE_IN_QUAD)"), "output:\n{output}");
}
#[test]
fn profile_step_uses_rounded_conversion() {
let profile = Profile::default_erm_named("test");
assert_eq!(profile_step(Some(&profile)), Some(2056));
let mut p1 = Profile::default_erm_named("test");
p1.duty_step = 1.0 / 255.0 * 100.0;
assert_eq!(profile_step(Some(&p1)), Some(257));
let mut p0 = Profile::default_erm_named("test");
p0.duty_step = 0.0;
assert_eq!(profile_step(Some(&p0)), Some(1));
assert_eq!(profile_step(None), None);
}
#[test]
fn profile_min_dt_prefers_min_dt_ms_over_ramp_step_ms() {
let mut profile = Profile::default_erm_named("test");
profile.min_dt_ms = 5;
profile.ramp_step_ms = 3;
assert_eq!(profile_min_dt(Some(&profile)), Some(5));
profile.min_dt_ms = 0;
assert_eq!(profile_min_dt(Some(&profile)), Some(3));
assert_eq!(profile_min_dt(None), None);
}
#[test]
fn generator_renders_ramp_with_explicit_overrides() {
let src = "\
haptic sweep motor=erm
ramp 50 0 100 linear step=500 rounding=floor min_dt=7
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let output = generate_rust(&options, &[], &haptics).unwrap();
assert!(output.contains("step: 500"), "output:\n{output}");
assert!(output.contains("Rounding::Floor"), "output:\n{output}");
assert!(output.contains("min_dt_ms: 7"), "output:\n{output}");
}
#[test]
fn generator_renders_ramp_with_ceil_rounding() {
let src = "\
haptic sweep motor=erm
ramp 50 0 100 linear rounding=ceil
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let output = generate_rust(&options, &[], &haptics).unwrap();
assert!(output.contains("Rounding::Ceil"), "output:\n{output}");
}
#[test]
fn generator_renders_gamma_ramp() {
let src = "\
haptic sweep motor=erm profile=gp
ramp 50 0 100 @gamma
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let mut profile = Profile::default_erm_named("gp");
profile.gamma = Some("ease_in_quad".to_owned());
let output = generate_rust(&options, &[profile], &haptics).unwrap();
assert!(output.contains("curve: EASE_IN_QUAD"), "output:\n{output}");
}
#[test]
fn step_align_above_min_rounds_up() {
assert_eq!(step_align_above_min(39321, 1000, 39321), 40000);
assert_eq!(step_align_above_min(40500, 1000, 39321), 40500);
assert_eq!(step_align_above_min(0, 1000, 39321), 0);
assert_eq!(step_align_above_min(39321, 1, 39321), 39321);
assert_eq!(step_align_above_min(100, 1000, 0), 100);
assert_eq!(step_align_above_min(40000, 1000, 40000), 40000);
assert_eq!(step_align_above_min(60000, 40000, 50000), 65535);
}
#[test]
fn generator_rejects_colliding_const_idents() {
let src = "\
haptic my_tap motor=erm
hold 5 50
end
haptic My_Tap motor=erm
hold 5 60
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let err = generate_rust(&options, &[], &haptics).unwrap_err();
assert!(err.contains("HAPTIC_MY_TAP"), "error: {err}");
assert!(
err.contains("my_tap") && err.contains("My_Tap"),
"error: {err}"
);
}
#[test]
fn generator_rejects_suffix_collision_across_haptics() {
let src = "\
haptic foo motor=erm
hold 5 50
end
haptic foo_program motor=erm
hold 5 60
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
let err = generate_rust(&options, &[], &haptics).unwrap_err();
assert!(err.contains("HAPTIC_FOO_PROGRAM"), "error: {err}");
}
#[test]
fn generator_accepts_distinct_names() {
let src = "\
haptic tap_a motor=erm
hold 5 50
end
haptic tap_b motor=erm
hold 5 60
end
";
let haptics = parse_haptics_document(src).unwrap();
let options = CodegenOptions {
curves_module: "crate::curves",
haptics_crate: "ph_haptics",
};
generate_rust(&options, &[], &haptics).unwrap();
}
#[test]
fn clamp_level_enforces_min_and_max() {
let mut profile = Profile::default_erm_named("test");
profile.min_level = 100.0 / 255.0 * 100.0;
profile.max_level = 200.0 / 255.0 * 100.0;
let low = clamp_level(1000, Some(&profile));
let min_frac = u32::from(Profile::percent_to_frac_u8(profile.min_level));
let min_expected = (min_frac * u32::from(u16::MAX) + 127) / 255;
assert_eq!(low, min_expected as u16);
let high = clamp_level(u16::MAX, Some(&profile));
let max_frac = u32::from(Profile::percent_to_frac_u8(profile.max_level));
let max_expected = (max_frac * u32::from(u16::MAX) + 127) / 255;
assert_eq!(high, max_expected as u16);
assert_eq!(clamp_level(0, Some(&profile)), 0);
assert_eq!(clamp_level(5000, None), 5000);
}
}