use std::collections::{BTreeMap, BTreeSet, HashMap};
#[allow(dead_code)]
enum RoundtripResult {
Pass,
Skip(String),
CompileFail(String),
Mismatch(String),
}
#[derive(Debug, PartialEq, Eq)]
struct LogicalGraph {
nodes: BTreeSet<LogicalNode>,
edges: BTreeSet<LogicalEdge>,
}
fn strip_disambiguation(text: &str) -> &str {
if let Some(pos) = text.rfind('#') {
let suffix = &text[pos + 1..];
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
return &text[..pos];
}
}
text
}
impl LogicalGraph {
fn eq_tolerant(&self, other: &LogicalGraph) -> bool {
let self_node_counts = Self::node_base_counts(&self.nodes);
let other_node_counts = Self::node_base_counts(&other.nodes);
if self_node_counts != other_node_counts {
return false;
}
let self_edge_counts = Self::edge_base_counts(&self.edges);
let other_edge_counts = Self::edge_base_counts(&other.edges);
self_edge_counts == other_edge_counts
}
fn node_base_counts(nodes: &BTreeSet<LogicalNode>) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
for node in nodes {
let base = strip_disambiguation(&node.text).to_string();
*counts.entry(base).or_insert(0) += 1;
}
counts
}
fn edge_base_counts(
edges: &BTreeSet<LogicalEdge>,
) -> BTreeMap<(String, u32, String, u32), usize> {
let mut counts = BTreeMap::new();
for edge in edges {
let key = (
strip_disambiguation(&edge.source_text).to_string(),
edge.source_outlet,
strip_disambiguation(&edge.dest_text).to_string(),
edge.dest_inlet,
);
*counts.entry(key).or_insert(0) += 1;
}
counts
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
struct LogicalNode {
maxclass: String,
text: String,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
struct LogicalEdge {
source_text: String,
source_outlet: u32,
dest_text: String,
dest_inlet: u32,
}
fn split_respecting_quotes(text: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in text.chars() {
if ch == '"' {
in_quotes = !in_quotes;
current.push(ch);
} else if ch.is_whitespace() && !in_quotes {
if !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
parts.push(current);
}
parts
}
fn normalize_object_text(text: &str) -> String {
let parts = split_respecting_quotes(text);
if parts.is_empty() {
return text.to_string();
}
let mut result_parts: Vec<String> = Vec::new();
let first = &parts[0];
let (name, extra) = split_op_num(first);
let name = if name
.chars()
.next()
.map_or(false, |c| c.is_ascii_digit() || c == '-')
&& name
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == 'e' || c == 'E')
{
result_parts.push("newobj".to_string());
result_parts.push(normalize_trailing_dot(&name));
name
} else if name.chars().next().map_or(false, |c| c.is_ascii_digit()) {
result_parts.push("newobj".to_string());
name
} else if name.starts_with('#') || name.starts_with('$') {
result_parts.push("newobj".to_string());
name
} else if name.starts_with('"') {
result_parts.push("newobj".to_string());
name
} else if !name.ends_with('~') && name.contains('.') {
let has_operator_segment = name.split('.').skip(1).any(|seg| {
seg.chars()
.next()
.map_or(false, |c| "*/%!<>=+-&|^".contains(c))
});
if has_operator_segment {
result_parts.push("newobj".to_string());
name
} else {
result_parts.push(name.clone());
name
}
} else {
result_parts.push(name.clone());
name
};
let _ = name;
if result_parts.last().map(|s| s.as_str()) == Some("t") {
*result_parts.last_mut().unwrap() = "trigger".to_string();
}
if let Some(arg) = extra {
result_parts.push(normalize_trailing_dot(&arg));
}
for p in &parts[1..] {
if p.starts_with('@') {
break;
}
let stripped = if p.starts_with('"') && p.ends_with('"') && p.len() >= 2 {
&p[1..p.len() - 1]
} else {
p.as_str()
};
result_parts.push(normalize_trailing_dot(stripped));
}
while result_parts.len() > 1 {
let last = result_parts.last().unwrap();
if last == "0" || last == "0.0" || last == "0." {
result_parts.pop();
} else {
break;
}
}
result_parts.join(" ")
}
fn split_op_num(token: &str) -> (String, Option<String>) {
let op_chars = |c: char| "*/%+-!<>=&|^".contains(c);
let chars: Vec<char> = token.chars().collect();
if chars.is_empty() || !op_chars(chars[0]) {
return (token.to_string(), None);
}
let mut op_end = 0;
for (i, &c) in chars.iter().enumerate() {
if op_chars(c) || c == '~' {
op_end = i + 1;
} else {
break;
}
}
if op_end >= chars.len() {
return (token.to_string(), None);
}
let rest_start = chars[op_end];
if rest_start.is_ascii_digit() || rest_start == '.' || rest_start == '-' {
let op: String = chars[..op_end].iter().collect();
let arg: String = chars[op_end..].iter().collect();
(op, Some(arg))
} else {
(token.to_string(), None)
}
}
fn normalize_trailing_dot(s: &str) -> String {
if s.ends_with('.') && s.len() > 1 {
let prefix = &s[..s.len() - 1];
let check = prefix.trim_start_matches('-');
if !check.is_empty() && check.chars().all(|c| c.is_ascii_digit()) {
return prefix.to_string();
}
}
s.to_string()
}
fn is_trigger_node(text: &str) -> bool {
let first_word = text.split_whitespace().next().unwrap_or("");
let base = strip_disambiguation(first_word);
base == "trigger" || base == "t"
}
fn is_value_preserving_trigger(text: &str) -> bool {
if !is_trigger_node(text) {
return false;
}
let parts: Vec<&str> = text.split_whitespace().collect();
let base_name = strip_disambiguation(parts[0]);
let _ = base_name;
if parts.len() <= 1 {
return false;
}
parts[1..].iter().all(|arg| {
let base = strip_disambiguation(arg);
matches!(base, "f" | "i" | "l" | "a" | "s")
})
}
fn check_code_field_preservation(orig_json: &str, regen_json: &str) -> Option<String> {
let orig: serde_json::Value = serde_json::from_str(orig_json).ok()?;
let regen: serde_json::Value = serde_json::from_str(regen_json).ok()?;
let orig_codes = extract_code_fields(&orig);
if orig_codes.is_empty() {
return None; }
let regen_codes = extract_code_fields(®en);
let mut missing = Vec::new();
for (maxclass, code) in &orig_codes {
let found = regen_codes
.iter()
.any(|(mc, rc)| mc == maxclass && rc == code);
if !found {
let preview: String = code.chars().take(60).collect();
missing.push(format!("{} code lost: \"{}...\"", maxclass, preview));
}
}
if missing.is_empty() {
None
} else {
Some(format!("code field not preserved: {}", missing.join("; ")))
}
}
fn extract_code_fields(root: &serde_json::Value) -> Vec<(String, String)> {
let mut result = Vec::new();
let boxes = match root.pointer("/patcher/boxes").and_then(|b| b.as_array()) {
Some(b) => b,
None => return result,
};
for bw in boxes {
let b = &bw["box"];
let maxclass = b["maxclass"].as_str().unwrap_or("");
if let Some(code) = b.get("code").and_then(|c| c.as_str()) {
if !code.is_empty() {
result.push((maxclass.to_string(), code.to_string()));
}
}
}
result
}
fn is_roundtrippable_varname(vn: &str) -> bool {
if vn.is_empty() {
return false;
}
let first = vn.chars().next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return false;
}
if !vn
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return false;
}
if matches!(
vn,
"wire"
| "in"
| "out"
| "feedback"
| "state"
| "msg"
| "signal"
| "float"
| "int"
| "bang"
| "list"
| "symbol"
) {
return false;
}
true
}
fn check_important_attrs_preserved(orig_json: &str, regen_json: &str) -> Option<String> {
let orig: serde_json::Value = serde_json::from_str(orig_json).ok()?;
let regen: serde_json::Value = serde_json::from_str(regen_json).ok()?;
let orig_boxes = orig.pointer("/patcher/boxes")?.as_array()?;
let regen_boxes = regen.pointer("/patcher/boxes")?.as_array()?;
let excluded_classes = ["comment", "inlet", "outlet", "panel", "fpic", "swatch"];
let orig_varnames: Vec<&str> = orig_boxes
.iter()
.filter(|bw| {
let mc = bw["box"]["maxclass"].as_str().unwrap_or("");
!excluded_classes.contains(&mc)
})
.filter_map(|bw| bw["box"]["varname"].as_str())
.filter(|vn| is_roundtrippable_varname(vn))
.collect();
let regen_varnames: Vec<&str> = regen_boxes
.iter()
.filter_map(|bw| bw["box"]["varname"].as_str())
.collect();
let mut missing_varnames = Vec::new();
for vn in &orig_varnames {
if !regen_varnames.contains(vn) {
missing_varnames.push(*vn);
}
}
if !missing_varnames.is_empty() {
return Some(format!("varname lost: {:?}", missing_varnames));
}
None
}
fn find_unnecessary_triggers(maxpat_json: &str) -> Vec<String> {
let root: serde_json::Value = match serde_json::from_str(maxpat_json) {
Ok(v) => v,
Err(_) => return vec![],
};
let boxes = match root.pointer("/patcher/boxes").and_then(|b| b.as_array()) {
Some(b) => b,
None => return vec![],
};
let lines = match root.pointer("/patcher/lines").and_then(|l| l.as_array()) {
Some(l) => l,
None => return vec![],
};
let mut id_to_text: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut trigger_ids: Vec<String> = Vec::new();
for bw in boxes {
let b = &bw["box"];
let id = b["id"].as_str().unwrap_or("").to_string();
let maxclass = b["maxclass"].as_str().unwrap_or("");
let text = b["text"].as_str().unwrap_or("").to_string();
if maxclass == "newobj" {
let first = text.split_whitespace().next().unwrap_or("");
if first == "trigger" || first == "t" {
trigger_ids.push(id.clone());
}
}
id_to_text.insert(id, text);
}
let mut unnecessary = Vec::new();
for tid in &trigger_ids {
let mut all_signal = true;
let mut has_dest = false;
for lw in lines {
let pl = &lw["patchline"];
let src_id = pl["source"]
.as_array()
.and_then(|a| a[0].as_str())
.unwrap_or("");
if src_id != tid {
continue;
}
has_dest = true;
let dst_id = pl["destination"]
.as_array()
.and_then(|a| a[0].as_str())
.unwrap_or("");
let dst_text = id_to_text.get(dst_id).map(|s| s.as_str()).unwrap_or("");
let dst_name = dst_text.split_whitespace().next().unwrap_or("");
if !dst_name.ends_with('~') {
all_signal = false;
break;
}
}
if has_dest && all_signal {
let text = id_to_text.get(tid).map(|s| s.as_str()).unwrap_or("trigger");
let normalized = normalize_object_text(text);
if is_value_preserving_trigger(&normalized) {
unnecessary.push(text.to_string());
}
}
}
unnecessary
}
fn extract_logical_graph(maxpat_json: &str) -> LogicalGraph {
let root: serde_json::Value =
serde_json::from_str(maxpat_json).expect("failed to parse .maxpat JSON");
let patcher = &root["patcher"];
let boxes = patcher["boxes"].as_array().expect("missing boxes array");
let lines = patcher["lines"].as_array().expect("missing lines array");
let mut raw_texts: Vec<(String, String, String, f64, f64)> = Vec::new();
let mut text_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut comment_ids: BTreeSet<String> = BTreeSet::new();
for box_wrapper in boxes {
let b = &box_wrapper["box"];
let id = b["id"].as_str().expect("box missing id").to_string();
let maxclass = b["maxclass"]
.as_str()
.expect("box missing maxclass")
.to_string();
if matches!(maxclass.as_str(), "comment" | "panel" | "fpic") {
comment_ids.insert(id);
continue;
}
let raw_text = if maxclass == "newobj" {
let full_text = match b["text"].as_str() {
Some(t) => t.to_string(),
None => {
maxclass.clone()
}
};
normalize_object_text(&full_text)
} else {
maxclass.clone()
};
let (x, y) = if let Some(rect) = b.get("patching_rect").and_then(|r| r.as_array()) {
(
rect.first().and_then(|v| v.as_f64()).unwrap_or(0.0),
rect.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0),
)
} else {
(0.0, 0.0)
};
*text_counts.entry(raw_text.clone()).or_insert(0) += 1;
raw_texts.push((id, maxclass, raw_text, y, x));
}
let mut id_to_node: HashMap<String, LogicalNode> = HashMap::new();
let mut dup_counters: HashMap<String, usize> = HashMap::new();
for (id, maxclass, raw_text, _y, _x) in &raw_texts {
let text = if text_counts[raw_text] > 1 {
let idx = dup_counters.entry(raw_text.clone()).or_insert(0);
let disambiguated = format!("{}#{}", raw_text, idx);
*idx += 1;
disambiguated
} else {
raw_text.clone()
};
id_to_node.insert(
id.clone(),
LogicalNode {
maxclass: maxclass.clone(),
text,
},
);
}
let mut edges: BTreeSet<LogicalEdge> = BTreeSet::new();
let trigger_ids: BTreeSet<String> = id_to_node
.iter()
.filter(|(_, node)| is_value_preserving_trigger(&node.text))
.map(|(id, _)| id.clone())
.collect();
let mut trigger_incoming: HashMap<String, Vec<(String, u32)>> = HashMap::new();
let mut trigger_outgoing: HashMap<(String, u32), Vec<(String, u32)>> = HashMap::new();
for line_wrapper in lines {
let patchline = &line_wrapper["patchline"];
let source = patchline["source"]
.as_array()
.expect("patchline missing source");
let dest = patchline["destination"]
.as_array()
.expect("patchline missing destination");
let source_id = source[0].as_str().expect("source id not a string");
let source_outlet = source[1].as_u64().expect("source outlet not a number") as u32;
let dest_id = dest[0].as_str().expect("dest id not a string");
let dest_inlet = dest[1].as_u64().expect("dest inlet not a number") as u32;
if comment_ids.contains(source_id) || comment_ids.contains(dest_id) {
continue;
}
if !id_to_node.contains_key(source_id) || !id_to_node.contains_key(dest_id) {
continue;
}
if trigger_ids.contains(dest_id) && dest_inlet == 0 {
trigger_incoming
.entry(dest_id.to_string())
.or_default()
.push((source_id.to_string(), source_outlet));
}
if trigger_ids.contains(source_id) {
trigger_outgoing
.entry((source_id.to_string(), source_outlet))
.or_default()
.push((dest_id.to_string(), dest_inlet));
}
if !trigger_ids.contains(source_id) && !trigger_ids.contains(dest_id) {
let source_node = &id_to_node[source_id];
let dest_node = &id_to_node[dest_id];
edges.insert(LogicalEdge {
source_text: source_node.text.clone(),
source_outlet,
dest_text: dest_node.text.clone(),
dest_inlet,
});
}
}
fn resolve_sources(
trigger_id: &str,
trigger_incoming: &HashMap<String, Vec<(String, u32)>>,
trigger_ids: &BTreeSet<String>,
depth: usize,
) -> Vec<(String, u32)> {
if depth > 20 {
return Vec::new();
}
let mut results = Vec::new();
if let Some(sources) = trigger_incoming.get(trigger_id) {
for (src_id, src_outlet) in sources {
if trigger_ids.contains(src_id) {
results.extend(resolve_sources(
src_id,
trigger_incoming,
trigger_ids,
depth + 1,
));
} else {
results.push((src_id.clone(), *src_outlet));
}
}
}
results
}
fn resolve_dests(
trigger_id: &str,
trigger_outgoing: &HashMap<(String, u32), Vec<(String, u32)>>,
trigger_ids: &BTreeSet<String>,
depth: usize,
) -> Vec<(String, u32)> {
if depth > 20 {
return Vec::new();
}
let mut results = Vec::new();
for ((tid, _outlet), dests) in trigger_outgoing {
if tid != trigger_id {
continue;
}
for (dest_id, dest_inlet) in dests {
if trigger_ids.contains(dest_id) {
results.extend(resolve_dests(
dest_id,
trigger_outgoing,
trigger_ids,
depth + 1,
));
} else {
results.push((dest_id.clone(), *dest_inlet));
}
}
}
results
}
let mut processed_triggers = BTreeSet::new();
for trigger_id in &trigger_ids {
if processed_triggers.contains(trigger_id) {
continue;
}
processed_triggers.insert(trigger_id.clone());
let sources = resolve_sources(trigger_id, &trigger_incoming, &trigger_ids, 0);
let dests = resolve_dests(trigger_id, &trigger_outgoing, &trigger_ids, 0);
for (src_id, src_outlet) in &sources {
let source_node = &id_to_node[src_id.as_str()];
for (dest_id, dest_inlet) in &dests {
let dest_node = &id_to_node[dest_id.as_str()];
edges.insert(LogicalEdge {
source_text: source_node.text.clone(),
source_outlet: *src_outlet,
dest_text: dest_node.text.clone(),
dest_inlet: *dest_inlet,
});
}
}
}
let nodes: BTreeSet<LogicalNode> = id_to_node
.values()
.filter(|n| !is_value_preserving_trigger(&n.text))
.cloned()
.collect();
LogicalGraph { nodes, edges }
}
fn find_all_maxpat_files(dir: &str) -> Vec<String> {
let mut files = Vec::new();
collect_maxpat_files(std::path::Path::new(dir), &mut files);
files.sort();
files
}
fn collect_maxpat_files(dir: &std::path::Path, files: &mut Vec<String>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_maxpat_files(&path, files);
} else if path.extension().and_then(|e| e.to_str()) == Some("maxpat") {
files.push(path.to_string_lossy().into_owned());
}
}
}
}
fn has_subpatchers(json_str: &str) -> bool {
let value: serde_json::Value = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(_) => return true, };
let boxes = match value.pointer("/patcher/boxes") {
Some(b) => b.as_array(),
None => return true,
};
if let Some(boxes) = boxes {
for item in boxes {
let box_obj = &item["box"];
if box_obj.get("patcher").is_some() {
return true;
}
if let Some(text) = box_obj["text"].as_str() {
let first_word = text.split_whitespace().next().unwrap_or("");
if matches!(
first_word,
"p" | "patcher"
| "poly~"
| "pfft~"
| "gen~"
| "jit.gen"
| "jit.pix"
| "vst~"
| "amxd~"
| "mc.gen~"
| "rnbo~"
) {
return true;
}
}
if box_obj["maxclass"].as_str() == Some("bpatcher") {
return true;
}
if matches!(
box_obj["maxclass"].as_str(),
Some("v8.codebox") | Some("codebox")
) {
if box_obj
.get("code")
.and_then(|c| c.as_str())
.map_or(false, |c| !c.is_empty())
{
return true;
}
}
}
}
false
}
fn decompile_multi_and_register(
json_str: &str,
base_name: &str,
) -> Result<
(
String,
flutmax_sema::registry::AbstractionRegistry,
std::collections::HashMap<String, String>,
std::collections::HashSet<String>,
std::collections::HashMap<String, String>,
std::collections::HashSet<String>,
std::collections::HashMap<String, String>,
),
RoundtripResult,
> {
let result = match flutmax_decompile::decompile_multi(json_str, base_name) {
Ok(r) => r,
Err(e) => return Err(RoundtripResult::Skip(format!("decompile_multi: {}", e))),
};
let mut registry = flutmax_sema::registry::AbstractionRegistry::new();
let mut rnbo_sources: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for filename in &result.rnbo_patchers {
if let Some(source) = result.files.get(filename) {
rnbo_sources.insert(filename.clone(), source.clone());
}
}
let mut gen_sources: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for filename in &result.gen_patchers {
if let Some(source) = result.files.get(filename) {
gen_sources.insert(filename.clone(), source.clone());
}
}
for (filename, source) in &result.files {
if *filename != result.main_file {
let name = filename.trim_end_matches(".flutmax");
match flutmax_parser::parse(source) {
Ok(ast) => {
registry.register(name, &ast);
}
Err(e) => {
return Err(RoundtripResult::CompileFail(format!(
"parse sub {}: {}",
filename, e
)));
}
}
}
}
let main_source = match result.files.get(&result.main_file) {
Some(s) => s.clone(),
None => {
return Err(RoundtripResult::Skip(
"no main file in decompile result".into(),
))
}
};
Ok((
main_source,
registry,
result.code_files,
result.rnbo_patchers,
rnbo_sources,
result.gen_patchers,
gen_sources,
))
}
fn test_single_roundtrip(path: &str) -> RoundtripResult {
let json_str = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return RoundtripResult::Skip(format!("read error: {}", e)),
};
let value: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(e) => return RoundtripResult::Skip(format!("invalid JSON: {}", e)),
};
if value.pointer("/patcher/boxes").is_none() {
return RoundtripResult::Skip("missing patcher/boxes".into());
}
if value.pointer("/patcher/lines").is_none() {
return RoundtripResult::Skip("missing patcher/lines".into());
}
let base_name = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("main");
let (main_source, registry, code_files, rnbo_patchers, rnbo_sources, gen_patchers, gen_sources) =
if has_subpatchers(&json_str) {
match decompile_multi_and_register(&json_str, base_name) {
Ok((source, reg, cf, rp, rs, gp, gs)) => (source, Some(reg), cf, rp, rs, gp, gs),
Err(result) => return result,
}
} else {
match flutmax_decompile::decompile(&json_str) {
Ok(s) => (
s,
None,
std::collections::HashMap::new(),
std::collections::HashSet::new(),
std::collections::HashMap::new(),
std::collections::HashSet::new(),
std::collections::HashMap::new(),
),
Err(e) => return RoundtripResult::Skip(format!("decompile: {}", e)),
}
};
let code_files_ref = if code_files.is_empty() {
None
} else {
Some(&code_files)
};
let regenerated_json = match flutmax_cli::compile_with_registry_and_code_files(
&main_source,
registry.as_ref(),
code_files_ref,
) {
Ok(s) => s,
Err(e) => return RoundtripResult::CompileFail(format!("{}", e)),
};
let orig_graph = match std::panic::catch_unwind(|| extract_logical_graph(&json_str)) {
Ok(g) => g,
Err(_) => {
return RoundtripResult::Skip("graph extraction panicked on original".into());
}
};
let regen_graph = match std::panic::catch_unwind(|| extract_logical_graph(®enerated_json)) {
Ok(g) => g,
Err(_) => {
return RoundtripResult::Mismatch(
"graph extraction panicked on regenerated output".into(),
);
}
};
if orig_graph.eq_tolerant(®en_graph) {
let unnecessary = find_unnecessary_triggers(®enerated_json);
if !unnecessary.is_empty() {
return RoundtripResult::Mismatch(format!(
"unnecessary trigger in signal path: {:?}",
unnecessary
));
}
if let Some(code_mismatch) = check_code_field_preservation(&json_str, ®enerated_json) {
return RoundtripResult::Mismatch(code_mismatch);
}
if let Some(attr_mismatch) = check_important_attrs_preserved(&json_str, ®enerated_json) {
return RoundtripResult::Mismatch(attr_mismatch);
}
if !rnbo_patchers.is_empty() {
let orig_value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let orig_boxes = orig_value["patcher"]["boxes"].as_array().unwrap();
for rnbo_filename in &rnbo_patchers {
let rnbo_name = rnbo_filename.trim_end_matches(".flutmax");
let orig_rnbo_json = orig_boxes.iter().find_map(|bw| {
let b = &bw["box"];
if let Some(patcher) = b.get("patcher") {
if patcher.get("classnamespace").and_then(|v| v.as_str()) == Some("rnbo") {
return Some(serde_json::json!({"patcher": patcher}));
}
}
None
});
if let Some(orig_rnbo) = orig_rnbo_json {
if let Some(rnbo_source) = rnbo_sources.get(rnbo_filename) {
match flutmax_cli::compile_rnbo(rnbo_source) {
Ok(regen_rnbo_json) => {
let orig_str = serde_json::to_string(&orig_rnbo).unwrap();
let orig_rnbo_graph = match std::panic::catch_unwind(|| {
extract_logical_graph(&orig_str)
}) {
Ok(g) => g,
Err(_) => continue, };
let regen_rnbo_graph = match std::panic::catch_unwind(|| {
extract_logical_graph(®en_rnbo_json)
}) {
Ok(g) => g,
Err(_) => {
return RoundtripResult::Mismatch(format!(
"RNBO subpatcher '{}' graph extraction panicked on regenerated output", rnbo_name
));
}
};
if !orig_rnbo_graph.eq_tolerant(®en_rnbo_graph) {
return RoundtripResult::Mismatch(format!(
"RNBO subpatcher '{}' graph mismatch",
rnbo_name
));
}
}
Err(e) => {
return RoundtripResult::CompileFail(format!(
"RNBO compile {}: {}",
rnbo_name, e
));
}
}
}
}
}
}
if !gen_patchers.is_empty() {
let orig_value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let orig_boxes = orig_value["patcher"]["boxes"].as_array().unwrap();
let orig_gen_patchers: Vec<serde_json::Value> = orig_boxes
.iter()
.filter_map(|bw| {
let b = &bw["box"];
if let Some(patcher) = b.get("patcher") {
if patcher.get("classnamespace").and_then(|v| v.as_str()) == Some("dsp.gen")
{
return Some(serde_json::json!({"patcher": patcher}));
}
}
None
})
.collect();
for gen_filename in &gen_patchers {
let gen_name = gen_filename.trim_end_matches(".flutmax");
if let Some(gen_source) = gen_sources.get(gen_filename) {
match flutmax_cli::compile_gen(gen_source) {
Ok(regen_gen_json) => {
let regen_gen_graph = match std::panic::catch_unwind(|| {
extract_logical_graph(®en_gen_json)
}) {
Ok(g) => g,
Err(_) => {
return RoundtripResult::Mismatch(format!(
"gen~ '{}' graph extraction panicked on regenerated output",
gen_name
));
}
};
let mut matched = false;
for orig_gen in &orig_gen_patchers {
let orig_str = serde_json::to_string(orig_gen).unwrap();
let orig_gen_graph = match std::panic::catch_unwind(|| {
extract_logical_graph(&orig_str)
}) {
Ok(g) => g,
Err(_) => continue,
};
if orig_gen_graph.eq_tolerant(®en_gen_graph) {
matched = true;
break;
}
}
if !matched {
let detail = if let Some(first_orig) = orig_gen_patchers.first() {
let orig_str = serde_json::to_string(first_orig).unwrap();
if let Ok(orig_g) = std::panic::catch_unwind(|| {
extract_logical_graph(&orig_str)
}) {
let orig_ec = LogicalGraph::edge_base_counts(&orig_g.edges);
let regen_ec =
LogicalGraph::edge_base_counts(®en_gen_graph.edges);
let missing_e: Vec<_> = orig_ec
.iter()
.filter(|(k, v)| regen_ec.get(k).unwrap_or(&0) < *v)
.take(5)
.map(|(k, _)| {
format!("{}:{}->{}:{}", k.0, k.1, k.2, k.3)
})
.collect();
let extra_e: Vec<_> = regen_ec
.iter()
.filter(|(k, v)| orig_ec.get(k).unwrap_or(&0) < *v)
.take(5)
.map(|(k, _)| {
format!("{}:{}->{}:{}", k.0, k.1, k.2, k.3)
})
.collect();
format!(
"missing_edges={:?}, extra_edges={:?}",
missing_e, extra_e
)
} else {
"graph extraction failed".to_string()
}
} else {
"no original gen~ patchers".to_string()
};
return RoundtripResult::Mismatch(format!(
"gen~ '{}': {}",
gen_name, detail
));
}
}
Err(e) => {
return RoundtripResult::CompileFail(format!(
"gen~ compile {}: {}",
gen_name, e
));
}
}
}
}
}
RoundtripResult::Pass
} else {
let orig_node_counts = LogicalGraph::node_base_counts(&orig_graph.nodes);
let regen_node_counts = LogicalGraph::node_base_counts(®en_graph.nodes);
let orig_edge_counts = LogicalGraph::edge_base_counts(&orig_graph.edges);
let regen_edge_counts = LogicalGraph::edge_base_counts(®en_graph.edges);
let mut detail = format!(
"orig={} nodes/{} edges, regen={} nodes/{} edges",
orig_graph.nodes.len(),
orig_graph.edges.len(),
regen_graph.nodes.len(),
regen_graph.edges.len(),
);
let missing_nodes: Vec<_> = orig_node_counts
.iter()
.filter(|(k, v)| regen_node_counts.get(*k).unwrap_or(&0) < *v)
.map(|(k, v)| {
let regen_count = regen_node_counts.get(k).unwrap_or(&0);
if *v > 1 || *regen_count > 0 {
format!("{}(x{}→x{})", k, v, regen_count)
} else {
k.clone()
}
})
.collect();
let extra_nodes: Vec<_> = regen_node_counts
.iter()
.filter(|(k, v)| orig_node_counts.get(*k).unwrap_or(&0) < *v)
.map(|(k, v)| {
let orig_count = orig_node_counts.get(k).unwrap_or(&0);
if *v > 1 || *orig_count > 0 {
format!("{}(x{}→x{})", k, orig_count, v)
} else {
k.clone()
}
})
.collect();
if !missing_nodes.is_empty() {
detail.push_str(&format!("\n missing nodes: {:?}", missing_nodes));
}
if !extra_nodes.is_empty() {
detail.push_str(&format!("\n extra nodes: {:?}", extra_nodes));
}
let missing_edges: Vec<_> = orig_edge_counts
.iter()
.filter(|(k, v)| regen_edge_counts.get(k).unwrap_or(&0) < *v)
.map(|(k, _)| format!("{}:{}->{}:{}", k.0, k.1, k.2, k.3))
.collect();
let extra_edges: Vec<_> = regen_edge_counts
.iter()
.filter(|(k, v)| orig_edge_counts.get(k).unwrap_or(&0) < *v)
.map(|(k, _)| format!("{}:{}->{}:{}", k.0, k.1, k.2, k.3))
.collect();
if !missing_edges.is_empty() {
detail.push_str(&format!("\n missing edges: {:?}", missing_edges));
}
if !extra_edges.is_empty() {
detail.push_str(&format!("\n extra edges: {:?}", extra_edges));
}
RoundtripResult::Mismatch(detail)
}
}
#[allow(dead_code)]
struct SuiteResults {
total: usize,
pass: usize,
skip: usize,
compile_fail: usize,
mismatch: usize,
compile_fail_details: Vec<(String, String)>,
mismatch_details: Vec<(String, String)>,
}
fn run_roundtrip_suite(label: &str, dir: &str) -> SuiteResults {
let maxpat_files = find_all_maxpat_files(dir);
let total = maxpat_files.len();
let mut pass = 0usize;
let mut skip = 0usize;
let mut compile_fail = 0usize;
let mut mismatch = 0usize;
let mut compile_fail_details: Vec<(String, String)> = Vec::new();
let mut mismatch_details: Vec<(String, String)> = Vec::new();
for (i, path) in maxpat_files.iter().enumerate() {
if (i + 1) % 100 == 0 || i + 1 == total {
eprint!("\r [{}/{}] processing...", i + 1, total);
}
let display_path = path.strip_prefix(dir).unwrap_or(path).to_string();
match test_single_roundtrip(path) {
RoundtripResult::Pass => pass += 1,
RoundtripResult::Skip(_) => skip += 1,
RoundtripResult::CompileFail(reason) => {
compile_fail += 1;
compile_fail_details.push((display_path, reason));
}
RoundtripResult::Mismatch(reason) => {
mismatch += 1;
mismatch_details.push((display_path, reason));
}
}
}
eprintln!();
eprintln!("\n=== {} Roundtrip Results ===", label);
eprintln!("Total: {}", total);
if total > 0 {
let pct = |n: usize| n as f64 / total as f64 * 100.0;
eprintln!("Pass: {:>4} ({:.1}%)", pass, pct(pass));
eprintln!("Skip: {:>4} ({:.1}%)", skip, pct(skip));
eprintln!(
"Compile fail: {:>4} ({:.1}%)",
compile_fail,
pct(compile_fail)
);
eprintln!("Mismatch: {:>4} ({:.1}%)", mismatch, pct(mismatch));
}
if !mismatch_details.is_empty() {
eprintln!("\n--- Graph Mismatches (FAIL) ---");
for (path, reason) in &mismatch_details {
eprintln!(" {}: {}", path, reason);
}
}
if !compile_fail_details.is_empty() {
eprintln!(
"\n--- Compile Failures ({} total, showing first 50) ---",
compile_fail_details.len()
);
for (path, reason) in compile_fail_details.iter().take(50) {
let short_reason = if reason.len() > 120 {
format!("{}...", &reason[..120])
} else {
reason.clone()
};
eprintln!(" {}: {}", path, short_reason);
}
if compile_fail_details.len() > 50 {
eprintln!(" ... and {} more", compile_fail_details.len() - 50);
}
}
SuiteResults {
total,
pass,
skip,
compile_fail,
mismatch,
compile_fail_details,
mismatch_details,
}
}
fn known_mismatch_paths() -> Vec<String> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/known_mismatches.txt");
match std::fs::read_to_string(path) {
Ok(content) => content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect(),
Err(_) => vec![],
}
}
#[test]
fn max_reference_roundtrip_all() {
let max_dir = match flutmax_validate::find_max_c74_dir() {
Some(dir) => dir,
None => {
eprintln!("SKIP: Max not installed (set MAX_INSTALL_PATH to override)");
return;
}
};
let results = run_roundtrip_suite("Max Reference", max_dir.to_str().unwrap());
if results.total == 0 {
eprintln!("WARNING: No .maxpat files found in {}", max_dir.display());
return;
}
let known = known_mismatch_paths();
let mut known_found: Vec<&(String, String)> = Vec::new();
let mut unexpected: Vec<&(String, String)> = Vec::new();
for entry in &results.mismatch_details {
if known.iter().any(|k| entry.0 == *k) {
known_found.push(entry);
} else {
unexpected.push(entry);
}
}
if !known_found.is_empty() {
eprintln!(
"\n--- Known Mismatches ({}/{} tracked) ---",
known_found.len(),
known.len()
);
for (path, _reason) in &known_found {
eprintln!(" {} (known)", path);
}
}
let fixed: Vec<&String> = known
.iter()
.filter(|k| !known_found.iter().any(|f| f.0 == **k))
.collect();
if !fixed.is_empty() {
eprintln!("\n--- Possibly Fixed (remove from known list) ---");
for path in &fixed {
eprintln!(" {}", path);
}
}
assert!(
unexpected.is_empty(),
"{} unexpected graph mismatches:\n{}",
unexpected.len(),
unexpected
.iter()
.map(|(p, r)| format!(" {}: {}", p, r))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn user_patches_roundtrip() {
let user_dir = match std::env::var("FLUTMAX_USER_PATCHES_DIR") {
Ok(dir) => dir,
Err(_) => {
eprintln!("SKIP: FLUTMAX_USER_PATCHES_DIR not set");
return;
}
};
if !std::path::Path::new(&user_dir).exists() {
eprintln!("SKIP: User Max patches directory not found at {}", user_dir);
return;
}
let results = run_roundtrip_suite("User Patches", &user_dir);
if results.total == 0 {
eprintln!("WARNING: No .maxpat files found in {}", user_dir);
return;
}
let user_known = vec!["max_mixer/mixer.maxpat", "max_mixer/mixer_test.maxpat"];
let unexpected_user: Vec<_> = results
.mismatch_details
.iter()
.filter(|(path, _)| !user_known.iter().any(|k| path == k))
.collect();
if !unexpected_user.is_empty() {
eprintln!("\n--- Unexpected User Patch Mismatches ---");
for (path, reason) in &unexpected_user {
eprintln!(" {}: {}", path, reason);
}
}
assert_eq!(
unexpected_user.len(),
0,
"{} unexpected user patch mismatches:\n{}",
unexpected_user.len(),
unexpected_user
.iter()
.map(|(p, r)| format!(" {}: {}", p, r))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn codebox_code_field_roundtrip() {
let path = "/Applications/Max.app/Contents/Resources/C74/packages/Jitter Geometry/patchers/abstractions/geom.edgelines.maxpat";
if !std::path::Path::new(path).exists() {
eprintln!("SKIP: test file not found");
return;
}
let json_str = std::fs::read_to_string(path).unwrap();
let base_name = "geom_edgelines";
let result = flutmax_decompile::decompile_multi(&json_str, base_name).unwrap();
assert!(
!result.code_files.is_empty(),
"decompile should extract code files from v8.codebox"
);
let mut registry = flutmax_sema::registry::AbstractionRegistry::new();
for (filename, source) in &result.files {
if *filename != result.main_file {
let name = filename.trim_end_matches(".flutmax");
let ast = flutmax_parser::parse(source).unwrap();
registry.register(name, &ast);
}
}
let main_source = result.files.get(&result.main_file).unwrap();
eprintln!(" main source:\n{}", main_source);
let regenerated = flutmax_cli::compile_with_registry_and_code_files(
main_source,
Some(®istry),
Some(&result.code_files),
)
.unwrap();
let regen_val: serde_json::Value = serde_json::from_str(®enerated).unwrap();
let regen_codes = extract_code_fields(®en_val);
let orig_val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let orig_codes = extract_code_fields(&orig_val);
eprintln!(" original code fields: {}", orig_codes.len());
eprintln!(" regenerated code fields: {}", regen_codes.len());
assert_eq!(
orig_codes.len(),
regen_codes.len(),
"code field count mismatch: orig={}, regen={}",
orig_codes.len(),
regen_codes.len()
);
for (maxclass, orig_code) in &orig_codes {
let found = regen_codes
.iter()
.any(|(mc, rc)| mc == maxclass && rc == orig_code);
assert!(
found,
"code not preserved for {}: {:?}...",
maxclass,
&orig_code[..orig_code.len().min(60)]
);
}
}
#[test]
fn real_patches_roundtrip() {
let real_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/real_patches/");
if !std::path::Path::new(real_dir).exists() {
eprintln!("SKIP: real_patches directory not found at {}", real_dir);
return;
}
let results = run_roundtrip_suite("Real Patches (GitHub)", real_dir);
if results.total == 0 {
eprintln!("WARNING: No .maxpat files found in {}", real_dir);
return;
}
let real_known = vec![
"slegroux_ambispat.maxpat",
"slegroux_slg.addanpca~.maxpat",
"slegroux_slg.addan~.maxpat",
"slegroux_slg.peaksynth~.maxpat",
"slegroux_spadTranche.maxpat",
];
let unexpected: Vec<_> = results
.mismatch_details
.iter()
.filter(|(path, _)| !real_known.iter().any(|k| path.contains(k)))
.collect();
if !unexpected.is_empty() {
eprintln!("\n--- Unexpected Real Patch Mismatches ---");
for (path, reason) in &unexpected {
eprintln!(" {}: {}", path, reason);
}
}
assert_eq!(
unexpected.len(),
0,
"{} unexpected real patch mismatches:\n{}",
unexpected.len(),
unexpected
.iter()
.map(|(p, r)| format!(" {}: {}", p, r))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn parser_migration_ast_equivalence() {
if let Some(max_dir) = flutmax_validate::find_max_c74_dir() {
run_parser_comparison("Max Reference", max_dir.to_str().unwrap());
}
let real_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/real_patches/");
if std::path::Path::new(real_dir).exists() {
run_parser_comparison("Real Patches", real_dir);
}
}
fn run_parser_comparison(label: &str, dir: &str) {
let maxpat_files = find_all_maxpat_files(dir);
let total = maxpat_files.len();
let mut compared = 0usize;
let mut skipped = 0usize;
let mut match_count = 0usize;
let mut mismatch_count = 0usize;
let mut parse_new_fail = 0usize;
let mut mismatch_details: Vec<String> = Vec::new();
let mut parse_fail_details: Vec<String> = Vec::new();
for (i, path) in maxpat_files.iter().enumerate() {
if (i + 1) % 200 == 0 || i + 1 == total {
eprint!("\r [{}/{}] comparing parsers...", i + 1, total);
}
let json_str = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => {
skipped += 1;
continue;
}
};
let flutmax_source = match flutmax_decompile::decompile(&json_str) {
Ok(s) => s,
Err(_) => {
skipped += 1;
continue;
}
};
if flutmax_source.trim().is_empty() {
skipped += 1;
continue;
}
let legacy_ast = match flutmax_parser::parse(&flutmax_source) {
Ok(ast) => ast,
Err(_) => {
skipped += 1;
continue;
}
};
let new_ast = match flutmax_parser::parse_new(&flutmax_source) {
Ok(ast) => ast,
Err(e) => {
parse_new_fail += 1;
let display_path = path.strip_prefix(dir).unwrap_or(path);
if parse_fail_details.len() < 20 {
parse_fail_details.push(format!("{}: {}", display_path, e));
}
continue;
}
};
compared += 1;
if legacy_ast == new_ast {
match_count += 1;
} else {
mismatch_count += 1;
let display_path = path.strip_prefix(dir).unwrap_or(path);
if mismatch_details.len() < 20 {
let diff = find_ast_diff(&legacy_ast, &new_ast);
mismatch_details.push(format!("{}: {}", display_path, diff));
}
}
}
eprintln!();
eprintln!("\n=== {} Parser Comparison ===", label);
eprintln!("Total files: {}", total);
eprintln!("Compared: {}", compared);
eprintln!("Skipped: {}", skipped);
eprintln!(
"AST match: {} ({:.1}%)",
match_count,
if compared > 0 {
match_count as f64 / compared as f64 * 100.0
} else {
0.0
}
);
eprintln!("AST mismatch: {}", mismatch_count);
eprintln!("New parse fail: {}", parse_new_fail);
if !parse_fail_details.is_empty() {
eprintln!(
"\n--- New Parser Failures (first {}) ---",
parse_fail_details.len()
);
for detail in &parse_fail_details {
eprintln!(" {}", detail);
}
}
if !mismatch_details.is_empty() {
eprintln!(
"\n--- AST Mismatches (first {}) ---",
mismatch_details.len()
);
for detail in &mismatch_details {
eprintln!(" {}", detail);
}
}
}
fn find_ast_diff(legacy: &flutmax_ast::Program, new: &flutmax_ast::Program) -> String {
if legacy.in_decls.len() != new.in_decls.len() {
return format!(
"in_decls count: {} vs {}",
legacy.in_decls.len(),
new.in_decls.len()
);
}
for (i, (ld, nd)) in legacy.in_decls.iter().zip(new.in_decls.iter()).enumerate() {
if ld != nd {
return format!("in_decls[{}]: {:?} vs {:?}", i, ld, nd);
}
}
if legacy.out_decls.len() != new.out_decls.len() {
return format!(
"out_decls count: {} vs {}",
legacy.out_decls.len(),
new.out_decls.len()
);
}
for (i, (ld, nd)) in legacy
.out_decls
.iter()
.zip(new.out_decls.iter())
.enumerate()
{
if ld != nd {
return format!("out_decls[{}]: {:?} vs {:?}", i, ld, nd);
}
}
if legacy.wires.len() != new.wires.len() {
return format!("wires count: {} vs {}", legacy.wires.len(), new.wires.len());
}
for (i, (lw, nw)) in legacy.wires.iter().zip(new.wires.iter()).enumerate() {
if lw.name != nw.name {
return format!("wire[{}] name: {:?} vs {:?}", i, lw.name, nw.name);
}
if lw.value != nw.value {
return format!(
"wire[{}] '{}' value: {:?} vs {:?}",
i, lw.name, lw.value, nw.value
);
}
if lw.attrs != nw.attrs {
return format!(
"wire[{}] '{}' attrs: {:?} vs {:?}",
i, lw.name, lw.attrs, nw.attrs
);
}
if lw.span != nw.span {
return format!(
"wire[{}] '{}' span: {:?} vs {:?}",
i, lw.name, lw.span, nw.span
);
}
}
if legacy.destructuring_wires != new.destructuring_wires {
return format!(
"destructuring_wires: {} vs {}",
legacy.destructuring_wires.len(),
new.destructuring_wires.len()
);
}
if legacy.msg_decls != new.msg_decls {
return format!(
"msg_decls: {} vs {}",
legacy.msg_decls.len(),
new.msg_decls.len()
);
}
if legacy.out_assignments != new.out_assignments {
return format!(
"out_assignments: {} vs {}",
legacy.out_assignments.len(),
new.out_assignments.len()
);
}
if legacy.direct_connections != new.direct_connections {
return format!(
"direct_connections: {} vs {}",
legacy.direct_connections.len(),
new.direct_connections.len()
);
}
if legacy.feedback_decls != new.feedback_decls {
return format!(
"feedback_decls: {} vs {}",
legacy.feedback_decls.len(),
new.feedback_decls.len()
);
}
if legacy.feedback_assignments != new.feedback_assignments {
return format!(
"feedback_assignments: {} vs {}",
legacy.feedback_assignments.len(),
new.feedback_assignments.len()
);
}
if legacy.state_decls != new.state_decls {
return format!(
"state_decls: {} vs {}",
legacy.state_decls.len(),
new.state_decls.len()
);
}
if legacy.state_assignments != new.state_assignments {
return format!(
"state_assignments: {} vs {}",
legacy.state_assignments.len(),
new.state_assignments.len()
);
}
let legacy_dbg = format!("{:?}", legacy);
let new_dbg = format!("{:?}", new);
for (i, (lc, nc)) in legacy_dbg.chars().zip(new_dbg.chars()).enumerate() {
if lc != nc {
let start = i.saturating_sub(20);
let end_l = (i + 40).min(legacy_dbg.len());
let end_n = (i + 40).min(new_dbg.len());
return format!(
"diff at char {}: legacy=...{}... new=...{}...",
i,
&legacy_dbg[start..end_l],
&new_dbg[start..end_n]
);
}
}
if legacy_dbg.len() != new_dbg.len() {
return format!(
"debug output length differs: {} vs {}",
legacy_dbg.len(),
new_dbg.len()
);
}
"unknown diff (PartialEq disagrees with Debug)".to_string()
}