use crate::model::{CurveRef, Haptic, Inst, LoopMode, Motor, Rounding};
use crate::names::validate_name;
use std::collections::{BTreeMap, BTreeSet};
pub(crate) const MAX_REPEAT_COUNT: u32 = 1024;
pub(crate) const MAX_INSTRUCTIONS: usize = 10_000;
pub(crate) fn parse_curve_symbols(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in text.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("pub const ")
&& let Some(name) = rest.split(':').next()
{
let ident = name.trim();
if !ident.is_empty() {
out.insert(ident.to_owned());
}
}
}
out
}
pub(crate) fn parse_haptics_document(source: &str) -> Result<Vec<Haptic>, String> {
let mut haptics = Vec::new();
let mut haptic_names = BTreeSet::new();
let mut current: Option<Haptic> = None;
let mut repeat_state: Option<(u32, u32, Vec<Inst>)> = None;
let mut completed: BTreeMap<String, (Motor, Vec<Inst>)> = BTreeMap::new();
for (index, raw_line) in source.lines().enumerate() {
let line_no = (index + 1) as u32;
let line = strip_comments(raw_line).trim();
if line.is_empty() {
continue;
}
if let Some(haptic) = &mut current {
if line == "end" {
if repeat_state.is_some() {
return Err(format!(
"line {line_no}: `end` inside unclosed `repeat` block"
));
}
ensure_instruction_budget(&haptic.instructions, line_no)?;
let done = current.take().expect("haptic state");
completed.insert(done.name.clone(), (done.motor, done.instructions.clone()));
haptics.push(done);
continue;
}
let first_token = line.split_whitespace().next().unwrap_or("");
if first_token == "repeat" {
if repeat_state.is_some() {
return Err(format!("line {line_no}: nested `repeat` is not allowed"));
}
let count_str = line
.split_whitespace()
.nth(1)
.ok_or(format!("line {line_no}: `repeat` requires a count"))?;
let count: u32 = count_str
.parse()
.map_err(|_| format!("line {line_no}: invalid repeat count `{count_str}`"))?;
if count == 0 {
return Err(format!("line {line_no}: repeat count must be >= 1"));
}
if count > MAX_REPEAT_COUNT {
return Err(format!(
"line {line_no}: repeat count {count} exceeds limit of {MAX_REPEAT_COUNT}"
));
}
repeat_state = Some((count, line_no, Vec::new()));
continue;
}
if first_token == "endrepeat" {
let (count, _start_line, body) = repeat_state.take().ok_or(format!(
"line {line_no}: `endrepeat` without matching `repeat`"
))?;
let projected = haptic
.instructions
.len()
.saturating_add(body.len().saturating_mul(count as usize));
ensure_len_budget(projected, line_no)?;
for _ in 0..count {
haptic.instructions.extend(body.iter().cloned());
}
continue;
}
if first_token == "use" {
let ref_name = line
.split_whitespace()
.nth(1)
.ok_or(format!("line {line_no}: `use` requires a haptic name"))?;
let (ref_motor, inlined) = completed.get(ref_name).ok_or(format!(
"line {line_no}: unknown haptic `{ref_name}` (forward references are not allowed)"
))?;
if *ref_motor != haptic.motor {
return Err(format!(
"line {line_no}: cannot `use` {} haptic `{ref_name}` from {} haptic `{}`",
motor_label(*ref_motor),
motor_label(haptic.motor),
haptic.name
));
}
if let Some((_, _, ref mut body)) = repeat_state {
ensure_len_budget(body.len().saturating_add(inlined.len()), line_no)?;
body.extend(inlined.iter().cloned());
} else {
haptic.instructions.extend(inlined.iter().cloned());
ensure_instruction_budget(&haptic.instructions, line_no)?;
}
continue;
}
let inst = parse_instruction(line, line_no)?;
if let Some((_, _, ref mut body)) = repeat_state {
ensure_len_budget(body.len().saturating_add(1), line_no)?;
body.push(inst);
} else {
haptic.instructions.push(inst);
ensure_instruction_budget(&haptic.instructions, line_no)?;
}
continue;
}
let mut tokens = line.split_whitespace();
let cmd = tokens
.next()
.ok_or(format!("line {line_no}: missing command"))?;
match cmd {
"haptic" => {
let haptic = parse_haptic_header(tokens, line_no)?;
if !haptic_names.insert(haptic.name.clone()) {
return Err(format!(
"line {line_no}: duplicate haptic `{}`",
haptic.name
));
}
current = Some(haptic);
}
_ => return Err(format!("line {line_no}: unknown top-level command `{cmd}`")),
}
}
if let Some((_, start_line, _)) = repeat_state {
return Err(format!("line {start_line}: unclosed `repeat` block"));
}
if let Some(haptic) = current {
return Err(format!(
"line {}: haptic `{}` missing `end`",
haptic.line, haptic.name
));
}
Ok(haptics)
}
fn ensure_instruction_budget(instructions: &[Inst], line_no: u32) -> Result<(), String> {
ensure_len_budget(instructions.len(), line_no)
}
fn ensure_len_budget(len: usize, line_no: u32) -> Result<(), String> {
if len > MAX_INSTRUCTIONS {
return Err(format!(
"line {line_no}: instruction count {len} exceeds limit of {MAX_INSTRUCTIONS}"
));
}
Ok(())
}
fn motor_label(motor: Motor) -> &'static str {
match motor {
Motor::Erm => "erm",
Motor::Lra => "lra",
}
}
fn parse_haptic_header<'a>(
mut tokens: impl Iterator<Item = &'a str>,
line: u32,
) -> Result<Haptic, String> {
let name = tokens
.next()
.ok_or(format!("line {line}: haptic requires a name"))?;
validate_name(name, line, "haptic")?;
let mut motor = None;
let mut loop_mode = LoopMode::Once;
let mut profile = None;
for token in tokens {
let Some((k, v)) = token.split_once('=') else {
return Err(format!("line {line}: invalid haptic option `{token}`"));
};
match k {
"motor" => {
motor = Some(match v {
"erm" => Motor::Erm,
"lra" => Motor::Lra,
_ => return Err(format!("line {line}: invalid motor `{v}`")),
});
}
"loop" => {
loop_mode = match v {
"once" => LoopMode::Once,
"forever" => LoopMode::Forever,
_ => {
let n: u32 = v.parse().map_err(|_| {
format!("line {line}: invalid loop mode `{v}` (expected once, forever, or a positive integer)")
})?;
if n == 0 {
return Err(format!("line {line}: loop count must be >= 1"));
}
LoopMode::Count(n)
}
};
}
"profile" => {
validate_name(v, line, "profile reference")?;
profile = Some(v.to_owned());
}
_ => return Err(format!("line {line}: unknown haptic option `{k}`")),
}
}
let motor = motor.ok_or(format!("line {line}: missing haptic motor=<erm|lra>"))?;
Ok(Haptic {
line,
name: name.to_owned(),
motor,
loop_mode,
profile,
instructions: Vec::new(),
})
}
fn parse_instruction(line: &str, line_no: u32) -> Result<Inst, String> {
let mut tokens = line.split_whitespace();
let cmd = tokens
.next()
.ok_or(format!("line {line_no}: missing instruction"))?;
match cmd {
"ramp" => {
let duration_ms = parse_token_duration(tokens.next(), line_no)?;
let from = parse_token_percent(tokens.next(), line_no, "from")?;
let to = parse_token_percent(tokens.next(), line_no, "to")?;
let curve_token = tokens
.next()
.ok_or(format!("line {line_no}: ramp missing curve"))?;
let curve = if curve_token == "@gamma" {
CurveRef::Gamma
} else {
CurveRef::Named(curve_token.to_owned())
};
let mut step = None;
let mut rounding = None;
let mut min_dt_ms = None;
let mut lra_hz = None;
let mut lra_hz_to = None;
for token in tokens {
let Some((k, v)) = token.split_once('=') else {
return Err(format!("line {line_no}: invalid option `{token}`"));
};
match k {
"step" => step = Some(parse_u16(v, line_no, "step")?),
"rounding" => {
rounding = Some(match v {
"nearest" => Rounding::Nearest,
"floor" => Rounding::Floor,
"ceil" => Rounding::Ceil,
_ => return Err(format!("line {line_no}: invalid rounding `{v}`")),
});
}
"min_dt" => min_dt_ms = Some(parse_u32(v, line_no, "min_dt")?),
"lra_hz" => lra_hz = Some(parse_u16(v, line_no, "lra_hz")?),
"lra_hz_to" => lra_hz_to = Some(parse_u16(v, line_no, "lra_hz_to")?),
_ => return Err(format!("line {line_no}: unknown option `{k}`")),
}
}
if lra_hz_to.is_some() && lra_hz.is_none() {
return Err(format!(
"line {line_no}: `lra_hz_to` requires `lra_hz` to be set"
));
}
Ok(Inst::Ramp {
line: line_no,
duration_ms,
from,
to,
curve,
step,
rounding,
min_dt_ms,
lra_hz,
lra_hz_to,
})
}
"hold" => {
let duration_ms = parse_token_duration(tokens.next(), line_no)?;
let level = parse_token_percent(tokens.next(), line_no, "level")?;
let mut lra_hz = None;
for token in tokens {
let Some((k, v)) = token.split_once('=') else {
return Err(format!("line {line_no}: invalid option `{token}`"));
};
match k {
"lra_hz" => lra_hz = Some(parse_u16(v, line_no, "lra_hz")?),
_ => return Err(format!("line {line_no}: unknown option `{k}`")),
}
}
Ok(Inst::Hold {
line: line_no,
duration_ms,
level,
lra_hz,
})
}
"pause" => {
let duration_ms = parse_token_duration(tokens.next(), line_no)?;
Ok(Inst::Pause {
line: line_no,
duration_ms,
})
}
_ => Err(format!("line {line_no}: unknown instruction `{cmd}`")),
}
}
fn parse_token_percent(value: Option<&str>, line: u32, field: &str) -> Result<u16, String> {
parse_percent(
value.ok_or(format!("line {line}: missing {field}"))?,
line,
field,
)
}
fn parse_percent(value: &str, line: u32, field: &str) -> Result<u16, String> {
let pct: f64 = value.parse().map_err(|_| {
format!("line {line}: invalid {field} `{value}` (expected 0..100 percentage)")
})?;
if !(0.0..=100.0).contains(&pct) {
return Err(format!(
"line {line}: {field} `{value}` out of range (expected 0..100)"
));
}
Ok((pct / 100.0 * f64::from(u16::MAX)).round() as u16)
}
fn parse_token_u32(value: Option<&str>, line: u32, field: &str) -> Result<u32, String> {
parse_u32(
value.ok_or(format!("line {line}: missing {field}"))?,
line,
field,
)
}
fn parse_token_duration(value: Option<&str>, line: u32) -> Result<u32, String> {
let ms = parse_token_u32(value, line, "duration")?;
if ms == 0 {
return Err(format!("line {line}: duration must be >= 1"));
}
Ok(ms)
}
fn parse_u16(value: &str, line: u32, field: &str) -> Result<u16, String> {
value
.parse::<u16>()
.map_err(|_| format!("line {line}: invalid {field} `{value}`"))
}
fn parse_u32(value: &str, line: u32, field: &str) -> Result<u32, String> {
value
.parse::<u32>()
.map_err(|_| format!("line {line}: invalid {field} `{value}`"))
}
fn strip_comments(line: &str) -> &str {
match line.find('#') {
Some(index) => &line[..index],
None => line,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_multiple_haptics() {
let src = "\
haptic click motor=erm
ramp 12 0 100 linear
pause 5
end
haptic buzz motor=lra loop=forever
hold 20 76.3 lra_hz=210
end
";
let haptics = parse_haptics_document(src).unwrap();
assert_eq!(haptics.len(), 2);
}
#[test]
fn parses_ramp_with_all_options() {
let src = "\
haptic test motor=erm
ramp 100 0 100 my_curve step=500 rounding=floor min_dt=5 lra_hz=200
end
";
let haptics = parse_haptics_document(src).unwrap();
assert_eq!(haptics[0].instructions.len(), 1);
match &haptics[0].instructions[0] {
Inst::Ramp {
duration_ms,
from,
to,
curve,
step,
rounding,
min_dt_ms,
lra_hz,
..
} => {
assert_eq!(*duration_ms, 100);
assert_eq!(*from, 0);
assert_eq!(*to, 65535);
assert_eq!(curve, &CurveRef::Named("my_curve".to_owned()));
assert_eq!(*step, Some(500));
assert_eq!(*rounding, Some(Rounding::Floor));
assert_eq!(*min_dt_ms, Some(5));
assert_eq!(*lra_hz, Some(200));
}
_ => panic!("expected ramp"),
}
}
#[test]
fn parses_gamma_curve_ref() {
let src = "\
haptic test motor=erm
ramp 50 0 48.83 @gamma
end
";
let haptics = parse_haptics_document(src).unwrap();
match &haptics[0].instructions[0] {
Inst::Ramp { curve, .. } => assert_eq!(curve, &CurveRef::Gamma),
_ => panic!("expected ramp"),
}
}
#[test]
fn rejects_zero_duration_hold() {
let src = "\
haptic test motor=erm
hold 0 76.3
end
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("duration must be >= 1"), "error: {err}");
}
#[test]
fn rejects_zero_duration_ramp() {
let src = "\
haptic test motor=erm
ramp 0 0 100 linear
end
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("duration must be >= 1"), "error: {err}");
}
#[test]
fn rejects_zero_duration_pause() {
let src = "\
haptic test motor=erm
pause 0
end
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("duration must be >= 1"), "error: {err}");
}
#[test]
fn strips_comments() {
let src = "\
# full line comment
haptic click motor=erm # inline comment
hold 10 45.78 # end of line
end
";
let haptics = parse_haptics_document(src).unwrap();
assert_eq!(haptics.len(), 1);
assert_eq!(haptics[0].name, "click");
}
#[test]
fn rejects_duplicate_haptic_names() {
let src = "\
haptic click motor=erm
hold 10 45.78
end
haptic click motor=erm
hold 10 45.78
end
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("duplicate haptic"), "error: {err}");
}
#[test]
fn rejects_missing_end() {
let src = "\
haptic click motor=erm
hold 10 45.78
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("missing `end`"), "error: {err}");
}
#[test]
fn rejects_unknown_top_level_command() {
let src = "unknown_cmd foo\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("unknown top-level command"), "error: {err}");
}
#[test]
fn rejects_unknown_instruction() {
let src = "\
haptic test motor=erm
wobble 10
end
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("unknown instruction"), "error: {err}");
}
#[test]
fn rejects_missing_motor() {
let src = "\
haptic test
hold 10 45.78
end
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("missing haptic motor"), "error: {err}");
}
#[test]
fn parse_curve_symbols_extracts_consts() {
let text = "\
pub const LINEAR: Foo = bar();
pub const EASE_IN: Baz = baz();
fn not_a_const() {}
static ALSO_NOT: u8 = 0;
";
let symbols = parse_curve_symbols(text);
assert_eq!(symbols.len(), 2);
assert!(symbols.contains("LINEAR"));
assert!(symbols.contains("EASE_IN"));
}
#[test]
fn parses_ramp_rounding_ceil_and_nearest() {
let src = "\
haptic test motor=erm
ramp 50 0 100 linear rounding=ceil
ramp 50 0 100 linear rounding=nearest
end
";
let haptics = parse_haptics_document(src).unwrap();
match &haptics[0].instructions[0] {
Inst::Ramp { rounding, .. } => assert_eq!(*rounding, Some(Rounding::Ceil)),
_ => panic!("expected ramp"),
}
match &haptics[0].instructions[1] {
Inst::Ramp { rounding, .. } => assert_eq!(*rounding, Some(Rounding::Nearest)),
_ => panic!("expected ramp"),
}
}
#[test]
fn rejects_invalid_motor_value() {
let src = "haptic test motor=piezo\nhold 10 45.78\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("invalid motor"), "error: {err}");
}
#[test]
fn rejects_invalid_loop_mode() {
let src = "haptic test motor=erm loop=twice\nhold 10 45.78\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("invalid loop mode"), "error: {err}");
}
#[test]
fn rejects_unknown_haptic_option() {
let src = "haptic test motor=erm speed=fast\nhold 10 45.78\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("unknown haptic option"), "error: {err}");
}
#[test]
fn rejects_invalid_haptic_option_format() {
let src = "haptic test motor=erm badtoken\nhold 10 45.78\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("invalid haptic option"), "error: {err}");
}
#[test]
fn rejects_invalid_rounding_value() {
let src = "haptic test motor=erm\nramp 50 0 100 linear rounding=random\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("invalid rounding"), "error: {err}");
}
#[test]
fn rejects_unknown_ramp_option() {
let src = "haptic test motor=erm\nramp 50 0 100 linear speed=fast\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("unknown option"), "error: {err}");
}
#[test]
fn rejects_unknown_hold_option() {
let src = "haptic test motor=erm\nhold 10 45.78 speed=fast\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("unknown option"), "error: {err}");
}
#[test]
fn rejects_non_numeric_duration() {
let src = "haptic test motor=erm\nhold abc 45.78\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("invalid"), "error: {err}");
}
#[test]
fn rejects_non_numeric_level() {
let src = "haptic test motor=erm\nhold 10 xyz\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("invalid"), "error: {err}");
}
#[test]
fn rejects_invalid_haptic_name() {
let src = "haptic 9bad motor=erm\nhold 10 45.78\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("invalid"), "error: {err}");
}
#[test]
fn rejects_out_of_range_percentage() {
let src = "haptic test motor=erm\nhold 10 101\nend\n";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("out of range"), "error: {err}");
}
#[test]
fn percentage_boundary_values() {
let src = "\
haptic test motor=erm
hold 10 0
hold 10 100
hold 10 50
end
";
let haptics = parse_haptics_document(src).unwrap();
match &haptics[0].instructions[0] {
Inst::Hold { level, .. } => assert_eq!(*level, 0),
_ => panic!("expected hold"),
}
match &haptics[0].instructions[1] {
Inst::Hold { level, .. } => assert_eq!(*level, 65535),
_ => panic!("expected hold"),
}
match &haptics[0].instructions[2] {
Inst::Hold { level, .. } => assert_eq!(*level, 32768), _ => panic!("expected hold"),
}
}
#[test]
fn repeat_expands_instructions() {
let src = "\
haptic test motor=erm
repeat 3
hold 1 50
endrepeat
end
";
let haptics = parse_haptics_document(src).unwrap();
assert_eq!(haptics[0].instructions.len(), 3);
}
#[test]
fn rejects_repeat_count_above_limit() {
let src = format!(
"\
haptic test motor=erm
repeat {}
hold 1 50
endrepeat
end
",
MAX_REPEAT_COUNT + 1
);
let err = parse_haptics_document(&src).unwrap_err();
assert!(err.contains("exceeds limit"), "error: {err}");
}
#[test]
fn rejects_post_expand_instruction_count_above_limit() {
let src = format!(
"\
haptic test motor=erm
repeat {MAX_REPEAT_COUNT}
hold 1 50
hold 1 60
hold 1 70
hold 1 80
hold 1 90
hold 1 40
hold 1 30
hold 1 20
hold 1 10
hold 1 5
endrepeat
end
"
);
let err = parse_haptics_document(&src).unwrap_err();
assert!(
err.contains("instruction count") && err.contains("exceeds limit"),
"error: {err}"
);
}
#[test]
fn rejects_cross_motor_use() {
let src = "\
haptic base motor=erm
hold 1 50
end
haptic other motor=lra
use base
end
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("cannot `use`"), "error: {err}");
assert!(err.contains("erm"), "error: {err}");
assert!(err.contains("lra"), "error: {err}");
}
#[test]
fn same_motor_use_is_allowed() {
let src = "\
haptic base motor=erm
hold 1 50
end
haptic other motor=erm
use base
end
";
let haptics = parse_haptics_document(src).unwrap();
assert_eq!(haptics[1].instructions.len(), 1);
}
#[test]
fn rejects_unclosed_repeat_at_eof() {
let src = "\
haptic test motor=erm
repeat 2
hold 1 50
";
let err = parse_haptics_document(src).unwrap_err();
assert!(err.contains("unclosed `repeat`"), "error: {err}");
}
}