#[path = "ph_haptics_gen/cli.rs"]
mod cli;
#[path = "ph_haptics_gen/codegen.rs"]
mod codegen;
#[path = "ph_haptics_gen/ident.rs"]
mod ident;
#[path = "ph_haptics_gen/model.rs"]
mod model;
#[path = "ph_haptics_gen/names.rs"]
mod names;
#[path = "ph_haptics_gen/parser.rs"]
mod parser;
#[path = "ph_haptics_gen/profiles.rs"]
mod profiles;
#[path = "ph_haptics_gen/semantic.rs"]
mod semantic;
#[path = "ph_haptics_gen/validate.rs"]
mod validate;
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
fn main() {
if let Err(error) = run() {
eprintln!("ph-haptics-gen: {error}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let action = cli::parse_cli(env::args().skip(1))?;
let cli = match action {
cli::CliAction::Help(help) => {
eprint!("{help}");
return Ok(());
}
cli::CliAction::Run(cli) => cli,
};
let input_path = cli
.input
.canonicalize()
.map_err(|error| format!("{}: {}", cli.input.display(), error))?;
let root_dir = input_path.parent().ok_or_else(|| {
format!(
"{}: input path has no parent directory",
cli.input.display()
)
})?;
let raw_source = read_file(&input_path)?;
let mut state = IncludeState::new(input_path.clone());
let haptics_source = resolve_includes(&raw_source, 0, root_dir, root_dir, &mut state)?;
let source_map = state.into_source_map(root_dir);
let profiles = profiles::load_profiles_toml(cli.profiles_toml.as_deref())?;
let haptics =
parser::parse_haptics_document(&haptics_source).map_err(|e| source_map.rewrite(&e))?;
let known_curves = if let Some(path) = &cli.curves_file {
Some(parser::parse_curve_symbols(&read_file(path)?))
} else {
None
};
validate::validate_haptics(&haptics, &profiles, known_curves.as_ref())
.map_err(|e| source_map.rewrite(&e))?;
let options = codegen::CodegenOptions {
curves_module: &cli.curves_module,
haptics_crate: &cli.haptics_crate,
};
let output = codegen::generate_rust(&options, &profiles, &haptics)?;
fs::write(&cli.output, output).map_err(|error| format!("{}: {}", cli.output.display(), error))
}
fn read_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|error| format!("{}: {}", path.display(), error))
}
struct IncludeState {
stack: Vec<PathBuf>,
expanded: BTreeSet<PathBuf>,
files: Vec<PathBuf>,
origins: Vec<(usize, u32)>,
}
impl IncludeState {
fn new(input: PathBuf) -> Self {
Self {
stack: Vec::new(),
expanded: BTreeSet::new(),
files: vec![input],
origins: Vec::new(),
}
}
fn file_index(&mut self, path: &Path) -> usize {
if let Some(index) = self.files.iter().position(|entry| entry == path) {
return index;
}
self.files.push(path.to_path_buf());
self.files.len() - 1
}
fn into_source_map(self, root_dir: &Path) -> SourceMap {
let files = self
.files
.iter()
.map(|path| {
path.strip_prefix(root_dir)
.unwrap_or(path)
.display()
.to_string()
})
.collect();
SourceMap {
files,
origins: self.origins,
}
}
}
struct SourceMap {
files: Vec<String>,
origins: Vec<(usize, u32)>,
}
impl SourceMap {
fn rewrite(&self, message: &str) -> String {
let Some(rest) = message.strip_prefix("line ") else {
return message.to_owned();
};
let Some((number, tail)) = rest.split_once(": ") else {
return message.to_owned();
};
let Ok(line_no) = number.parse::<usize>() else {
return message.to_owned();
};
let Some(&(file, original)) = line_no.checked_sub(1).and_then(|i| self.origins.get(i))
else {
return message.to_owned();
};
let Some(name) = self.files.get(file) else {
return message.to_owned();
};
format!("{name}:{original}: {tail}")
}
}
fn resolve_includes(
source: &str,
file_index: usize,
base_dir: &Path,
root_dir: &Path,
state: &mut IncludeState,
) -> Result<String, String> {
let mut out = String::new();
for (index, raw_line) in source.lines().enumerate() {
let line_no = index + 1;
let trimmed = raw_line.trim();
if let Some(rest) = trimmed.strip_prefix("include ") {
let path_str = parse_include_path(rest, line_no)?;
let include_path = Path::new(path_str);
if include_path.is_absolute() {
return Err(format!(
"line {line_no}: include `{path_str}`: absolute paths are not allowed"
));
}
let resolved = base_dir.join(path_str);
let canonical = resolved
.canonicalize()
.map_err(|e| format!("line {line_no}: include `{path_str}`: {e}"))?;
if !canonical.starts_with(root_dir) {
return Err(format!(
"line {line_no}: include `{path_str}` escapes input directory"
));
}
if state.stack.iter().any(|entry| entry == &canonical) {
return Err(format!("line {line_no}: circular include `{path_str}`"));
}
if !state.expanded.insert(canonical.clone()) {
out.push('\n');
state.origins.push((file_index, line_no as u32));
continue;
}
let included_index = state.file_index(&canonical);
state.stack.push(canonical.clone());
let included_source = read_file(&resolved)
.map_err(|e| format!("line {line_no}: include `{path_str}`: {e}"))?;
let include_dir = canonical.parent().unwrap_or(base_dir).to_path_buf();
let expanded = resolve_includes(
&included_source,
included_index,
&include_dir,
root_dir,
state,
)?;
state.stack.pop();
out.push_str(&expanded);
out.push('\n');
state.origins.push((file_index, line_no as u32));
} else {
out.push_str(raw_line);
out.push('\n');
state.origins.push((file_index, line_no as u32));
}
}
Ok(out)
}
fn parse_include_path(rest: &str, line_no: usize) -> Result<&str, String> {
let rest = match rest.find('#') {
Some(index) => &rest[..index],
None => rest,
};
let rest = rest.trim();
let Some(rest) = rest.strip_prefix('"') else {
return Err(format!(
"line {line_no}: include path must be a double-quoted string"
));
};
let Some(end) = rest.find('"') else {
return Err(format!(
"line {line_no}: include path missing closing quote"
));
};
let path = &rest[..end];
let after = rest[end + 1..].trim();
if !after.is_empty() {
return Err(format!(
"line {line_no}: unexpected tokens after include path"
));
}
if path.is_empty() {
return Err(format!("line {line_no}: empty include path"));
}
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!(
"ph_haptics_gen_include_{name}_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn write(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn include_trailing_hash_comment_is_stripped() {
let root = temp_dir("comment");
write(
&root.join("child.phh"),
"haptic x motor=erm\nhold 1 50\nend\n",
);
write(
&root.join("main.phh"),
"include \"child.phh\" # trailing comment\n",
);
let root = root.canonicalize().unwrap();
let source = read_file(&root.join("main.phh")).unwrap();
let expanded = resolve_includes(
&source,
0,
&root,
&root,
&mut IncludeState::new(root.join("main.phh")),
)
.unwrap();
assert!(expanded.contains("haptic x motor=erm"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn include_rejects_path_traversal_outside_root() {
let root = temp_dir("jail");
let outside = root
.join("..")
.join(format!("ph_haptics_gen_outside_{}", std::process::id()));
write(
&outside.join("secret.phh"),
"haptic leaked motor=erm\nhold 1 1\nend\n",
);
write(
&root.join("main.phh"),
&format!(
"include \"../ph_haptics_gen_outside_{}/secret.phh\"\n",
std::process::id()
),
);
let root_canon = root.canonicalize().unwrap();
let source = read_file(&root_canon.join("main.phh")).unwrap();
let err = resolve_includes(
&source,
0,
&root_canon,
&root_canon,
&mut IncludeState::new(root_canon.join("main.phh")),
)
.unwrap_err();
assert!(err.contains("escapes input directory"), "error: {err}");
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&outside);
}
#[test]
fn include_rejects_absolute_path() {
let root = temp_dir("abs");
let child = root.join("child.phh");
write(&child, "haptic x motor=erm\nhold 1 50\nend\n");
let abs = child.canonicalize().unwrap();
let abs_str = abs.to_string_lossy().replace('\\', "/");
write(&root.join("main.phh"), &format!("include \"{abs_str}\"\n"));
let root_canon = root.canonicalize().unwrap();
let source = read_file(&root_canon.join("main.phh")).unwrap();
let err = resolve_includes(
&source,
0,
&root_canon,
&root_canon,
&mut IncludeState::new(root_canon.join("main.phh")),
)
.unwrap_err();
assert!(
err.contains("absolute paths are not allowed"),
"error: {err}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn include_diamond_expands_shared_file_once() {
let root = temp_dir("diamond");
write(
&root.join("shared.phh"),
"haptic shared motor=erm\nhold 1 10\nend\n",
);
write(&root.join("a.phh"), "include \"shared.phh\"\n");
write(&root.join("b.phh"), "include \"shared.phh\"\n");
write(
&root.join("main.phh"),
"include \"a.phh\"\ninclude \"b.phh\"\n",
);
let root = root.canonicalize().unwrap();
let source = read_file(&root.join("main.phh")).unwrap();
let expanded = resolve_includes(
&source,
0,
&root,
&root,
&mut IncludeState::new(root.join("main.phh")),
)
.unwrap();
assert_eq!(expanded.matches("haptic shared").count(), 1);
let haptics = parser::parse_haptics_document(&expanded).unwrap();
assert_eq!(haptics.len(), 1);
assert_eq!(haptics[0].name, "shared");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn errors_in_included_files_report_the_real_file_and_line() {
let root = temp_dir("sourcemap");
write(
&root.join("lib").join("pads.phh"),
"haptic pad_a motor=erm\nhold 5 50\nend\n",
);
write(
&root.join("lib").join("broken.phh"),
"# shared helpers\n\nhaptic broken motor=erm\nhold 5 50\nwobble 9\nend\n",
);
write(
&root.join("main.phh"),
"include \"lib/pads.phh\"\ninclude \"lib/broken.phh\"\n",
);
let root = root.canonicalize().unwrap();
let source = read_file(&root.join("main.phh")).unwrap();
let mut state = IncludeState::new(root.join("main.phh"));
let expanded = resolve_includes(&source, 0, &root, &root, &mut state).unwrap();
let map = state.into_source_map(&root);
let err = parser::parse_haptics_document(&expanded).unwrap_err();
assert!(err.starts_with("line "), "raw error: {err}");
let mapped = map.rewrite(&err);
assert!(mapped.contains("broken.phh:5"), "mapped error: {mapped}");
assert!(
mapped.contains("unknown instruction `wobble`"),
"mapped error: {mapped}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn source_map_passes_through_unprefixed_messages() {
let map = SourceMap {
files: vec!["main.phh".to_owned()],
origins: vec![(0, 1)],
};
let message = "generated symbol `HAPTIC_X` is claimed by both a and b";
assert_eq!(map.rewrite(message), message);
assert_eq!(map.rewrite("line 99: boom"), "line 99: boom");
}
#[test]
fn include_once_does_not_mask_real_duplicates() {
let root = temp_dir("dupe");
write(
&root.join("a.phh"),
"haptic clash motor=erm\nhold 1 10\nend\n",
);
write(
&root.join("b.phh"),
"haptic clash motor=erm\nhold 2 20\nend\n",
);
write(
&root.join("main.phh"),
"include \"a.phh\"\ninclude \"b.phh\"\n",
);
let root = root.canonicalize().unwrap();
let source = read_file(&root.join("main.phh")).unwrap();
let expanded = resolve_includes(
&source,
0,
&root,
&root,
&mut IncludeState::new(root.join("main.phh")),
)
.unwrap();
let err = parser::parse_haptics_document(&expanded).unwrap_err();
assert!(err.contains("duplicate haptic"), "error: {err}");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn include_cycle_still_fails() {
let root = temp_dir("cycle");
write(&root.join("a.phh"), "include \"b.phh\"\n");
write(&root.join("b.phh"), "include \"a.phh\"\n");
write(&root.join("main.phh"), "include \"a.phh\"\n");
let root = root.canonicalize().unwrap();
let source = read_file(&root.join("main.phh")).unwrap();
let err = resolve_includes(
&source,
0,
&root,
&root,
&mut IncludeState::new(root.join("main.phh")),
)
.unwrap_err();
assert!(err.contains("circular include"), "error: {err}");
let _ = fs::remove_dir_all(&root);
}
}