use crate::types::{Symbol, SymbolKind};
use std::collections::{BTreeSet, HashMap};
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
struct MemberFingerprint {
kind: SymbolKind,
return_type: Option<String>,
is_optional: bool,
param_count: usize,
}
impl MemberFingerprint {
fn from_symbol(sym: &Symbol) -> Self {
let (return_type, is_optional, param_count) = match &sym.signature {
Some(sig) => (
sig.return_type.clone(),
sig.parameters.first().map(|p| p.optional).unwrap_or(false),
sig.parameters.len(),
),
None => (None, false, 0),
};
Self {
kind: sym.kind,
return_type,
is_optional,
param_count,
}
}
fn from_symbol_normalized(sym: &Symbol) -> Self {
let (return_type, is_optional, param_count) = match &sym.signature {
Some(sig) => (
sig.return_type
.as_ref()
.map(|t| normalize_type_structure(t)),
sig.parameters.first().map(|p| p.optional).unwrap_or(false),
sig.parameters.len(),
),
None => (None, false, 0),
};
Self {
kind: sym.kind,
return_type,
is_optional,
param_count,
}
}
fn from_symbol_deep_normalized(sym: &Symbol) -> Self {
let (return_type, is_optional, param_count) = match &sym.signature {
Some(sig) => (
sig.return_type
.as_ref()
.map(|t| normalize_type_structure_deep(t)),
sig.parameters.first().map(|p| p.optional).unwrap_or(false),
sig.parameters.len(),
),
None => (None, false, 0),
};
Self {
kind: sym.kind,
return_type,
is_optional,
param_count,
}
}
}
pub(crate) fn normalize_type_structure(type_str: &str) -> String {
let result = regex_replace_all_pascal_case(type_str, "_T_");
regex_replace_all_param_names(&result, "_p_")
}
fn regex_replace_all_pascal_case(s: &str, placeholder: &str) -> String {
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_uppercase() {
let start = i;
i += 1;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
let word = &s[start..i];
if word.chars().any(|c| c.is_ascii_lowercase()) {
result.push_str(placeholder);
} else {
result.push_str(word);
}
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
fn regex_replace_all_param_names(s: &str, placeholder: &str) -> String {
let mut result = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].is_ascii_lowercase() {
let start = i;
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
i += 1;
}
let mut j = i;
while j < chars.len() && chars[j].is_ascii_whitespace() {
j += 1;
}
if j < chars.len() && chars[j] == ':' {
result.push_str(placeholder);
for ch in &chars[i..=j] {
result.push(*ch);
}
i = j + 1;
} else {
result.push_str(&s[start..i]);
}
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
fn normalize_type_structure_deep(type_str: &str) -> String {
let step1 = normalize_type_structure(type_str);
let mut result = String::with_capacity(step1.len());
let mut in_quote = false;
for ch in step1.chars() {
if ch == '\'' {
if !in_quote {
in_quote = true;
result.push_str("'_V_'");
} else {
in_quote = false;
}
} else if !in_quote {
result.push(ch);
}
}
while result.contains("'_V_' | '_V_'") {
result = result.replace("'_V_' | '_V_'", "'_V_'");
}
result
}
pub(super) struct RenameMatch<'a> {
pub old: &'a Symbol,
pub new: &'a Symbol,
}
pub(super) fn detect_renames<'a>(
removed: &[&'a Symbol],
added: &[&'a Symbol],
) -> Vec<RenameMatch<'a>> {
if removed.is_empty() || added.is_empty() {
return Vec::new();
}
let mut added_by_fp: HashMap<MemberFingerprint, Vec<(usize, &'a Symbol)>> = HashMap::new();
for (ai, sym) in added.iter().enumerate() {
let fp = MemberFingerprint::from_symbol(sym);
added_by_fp.entry(fp).or_default().push((ai, sym));
}
let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
const MAX_GROUP_SIZE: usize = 50;
let mut removed_by_fp: HashMap<MemberFingerprint, usize> = HashMap::new();
for rsym in removed.iter() {
let fp = MemberFingerprint::from_symbol(rsym);
*removed_by_fp.entry(fp).or_default() += 1;
}
for (ri, rsym) in removed.iter().enumerate() {
let fp = MemberFingerprint::from_symbol(rsym);
let removed_count = removed_by_fp.get(&fp).copied().unwrap_or(0);
if let Some(added_syms) = added_by_fp.get(&fp) {
if removed_count > MAX_GROUP_SIZE || added_syms.len() > MAX_GROUP_SIZE {
continue;
}
for (ai, asym) in added_syms {
let sim = name_similarity(&rsym.name, &asym.name);
candidates.push((ri, *ai, sim));
}
}
}
let mut structural_fp: HashMap<MemberFingerprint, Vec<(usize, &Symbol)>> = HashMap::new();
for (ai, sym) in added.iter().enumerate() {
let fp = MemberFingerprint::from_symbol_normalized(sym);
structural_fp.entry(fp).or_default().push((ai, sym));
}
for (ri, rsym) in removed.iter().enumerate() {
let fp = MemberFingerprint::from_symbol_normalized(rsym);
if let Some(added_syms) = structural_fp.get(&fp) {
if added_syms.len() > MAX_GROUP_SIZE {
continue;
}
for (ai, asym) in added_syms {
let already = candidates.iter().any(|(r, a, _)| *r == ri && *a == *ai);
if already {
continue;
}
let sim = name_similarity(&rsym.name, &asym.name);
if sim >= MIN_SIMILARITY {
candidates.push((ri, *ai, sim));
}
}
}
}
let mut deep_fp: HashMap<MemberFingerprint, Vec<(usize, &Symbol)>> = HashMap::new();
for (ai, sym) in added.iter().enumerate() {
let fp = MemberFingerprint::from_symbol_deep_normalized(sym);
deep_fp.entry(fp).or_default().push((ai, sym));
}
for (ri, rsym) in removed.iter().enumerate() {
let fp = MemberFingerprint::from_symbol_deep_normalized(rsym);
if let Some(added_syms) = deep_fp.get(&fp) {
if added_syms.len() > MAX_GROUP_SIZE {
continue;
}
for (ai, asym) in added_syms {
let already = candidates.iter().any(|(r, a, _)| *r == ri && *a == *ai);
if already {
continue;
}
let sim = name_similarity(&rsym.name, &asym.name);
if sim >= MIN_SIMILARITY {
candidates.push((ri, *ai, sim));
}
}
}
}
{
const NAME_ONLY_SIMILARITY: f64 = 0.6;
let mut removed_by_parent: HashMap<&str, Vec<(usize, &Symbol)>> = HashMap::new();
let mut added_by_parent: HashMap<&str, Vec<(usize, &Symbol)>> = HashMap::new();
for (ri, rsym) in removed.iter().enumerate() {
if rsym.kind != SymbolKind::Property {
continue;
}
if let Some((parent, _)) = rsym.qualified_name.rsplit_once('.') {
removed_by_parent
.entry(parent)
.or_default()
.push((ri, rsym));
}
}
for (ai, asym) in added.iter().enumerate() {
if asym.kind != SymbolKind::Property {
continue;
}
if let Some((parent, _)) = asym.qualified_name.rsplit_once('.') {
added_by_parent.entry(parent).or_default().push((ai, asym));
}
}
for (parent, removed_props) in &removed_by_parent {
let added_props = match added_by_parent.get(parent) {
Some(a) => a,
None => continue,
};
if removed_props.len() > MAX_GROUP_SIZE || added_props.len() > MAX_GROUP_SIZE {
continue;
}
for (ri, rsym) in removed_props {
for (ai, asym) in added_props {
let already = candidates.iter().any(|(r, a, _)| *r == *ri && *a == *ai);
if already {
continue;
}
let sim = name_similarity(&rsym.name, &asym.name);
if sim >= NAME_ONLY_SIMILARITY {
candidates.push((*ri, *ai, sim));
}
}
}
}
}
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let mut used_removed = vec![false; removed.len()];
let mut used_added = vec![false; added.len()];
let mut matches = Vec::new();
const MIN_SIMILARITY: f64 = 0.15;
for (ri, ai, sim) in candidates {
if sim < MIN_SIMILARITY {
continue;
}
if used_removed[ri] || used_added[ai] {
continue;
}
used_removed[ri] = true;
used_added[ai] = true;
matches.push(RenameMatch {
old: removed[ri],
new: added[ai],
});
}
matches
}
pub(super) fn detect_token_renames<'a>(
removed: &[&'a Symbol],
added: &[&'a Symbol],
) -> Vec<RenameMatch<'a>> {
use std::collections::{BTreeSet, HashSet};
let removed_tokens: Vec<(usize, &Symbol, BTreeSet<String>)> = removed
.iter()
.enumerate()
.filter(|(_, s)| matches!(s.kind, SymbolKind::Constant | SymbolKind::Variable))
.map(|(i, s)| {
let segments = tokenize_name(&s.name);
(i, *s, segments)
})
.collect();
let added_tokens: Vec<(usize, &Symbol, BTreeSet<String>)> = added
.iter()
.enumerate()
.filter(|(_, s)| matches!(s.kind, SymbolKind::Constant | SymbolKind::Variable))
.map(|(i, s)| {
let segments = tokenize_name(&s.name);
(i, *s, segments)
})
.collect();
if removed_tokens.is_empty() || added_tokens.is_empty() {
return Vec::new();
}
tracing::debug!(
removed = removed_tokens.len(),
added = added_tokens.len(),
"Starting token rename detection"
);
let mut segment_index: HashMap<String, Vec<usize>> = HashMap::new();
for (idx, (_, _, segments)) in added_tokens.iter().enumerate() {
for seg in segments {
segment_index.entry(seg.clone()).or_default().push(idx);
}
}
const MIN_JACCARD: f64 = 0.6;
let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
for (ri_local, (_, _, r_segments)) in removed_tokens.iter().enumerate() {
if r_segments.is_empty() {
continue;
}
let mut hit_counts: HashMap<usize, usize> = HashMap::new();
for seg in r_segments {
if let Some(added_indices) = segment_index.get(seg) {
for &ai in added_indices {
*hit_counts.entry(ai).or_default() += 1;
}
}
}
let min_shared = (r_segments.len() as f64 * 0.6).ceil() as usize;
let min_shared = min_shared.max(2);
for (ai_local, hits) in hit_counts {
if hits < min_shared {
continue;
}
let a_segments = &added_tokens[ai_local].2;
let intersection = r_segments.intersection(a_segments).count();
let union = r_segments.union(a_segments).count();
let jaccard = if union > 0 {
intersection as f64 / union as f64
} else {
0.0
};
if jaccard >= MIN_JACCARD {
candidates.push((ri_local, ai_local, jaccard));
}
}
}
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let mut used_removed = HashSet::new();
let mut used_added = HashSet::new();
let mut matches = Vec::new();
for (ri_local, ai_local, jaccard) in &candidates {
if used_removed.contains(ri_local) || used_added.contains(ai_local) {
continue;
}
used_removed.insert(*ri_local);
used_added.insert(*ai_local);
let old_sym = removed_tokens[*ri_local].1;
let new_sym = added_tokens[*ai_local].1;
tracing::debug!(
old = %old_sym.name,
new = %new_sym.name,
jaccard = %jaccard,
"Token rename matched"
);
matches.push(RenameMatch {
old: old_sym,
new: new_sym,
});
}
let jaccard_matched = matches.len();
{
let mut value_to_added: HashMap<String, Vec<usize>> = HashMap::new();
for (ai_local, (_, sym, _)) in added_tokens.iter().enumerate() {
if used_added.contains(&ai_local) {
continue;
}
if let Some(val) = extract_token_value(sym) {
value_to_added.entry(val).or_default().push(ai_local);
}
}
let mut value_matches = 0usize;
for (ri_local, (_, sym, _)) in removed_tokens.iter().enumerate() {
if used_removed.contains(&ri_local) {
continue;
}
let old_value = match extract_token_value(sym) {
Some(v) => v,
None => continue,
};
if let Some(candidates) = value_to_added.get(&old_value) {
let old_segments = tokenize_name(&removed_tokens[ri_local].1.name);
let mut sorted: Vec<usize> = candidates
.iter()
.copied()
.filter(|ai| !used_added.contains(ai))
.collect();
sorted.sort_by(|a, b| {
let seg_a = tokenize_name(&added_tokens[*a].1.name);
let seg_b = tokenize_name(&added_tokens[*b].1.name);
let overlap_a = old_segments.intersection(&seg_a).count();
let overlap_b = old_segments.intersection(&seg_b).count();
overlap_b.cmp(&overlap_a)
});
if let Some(&ai_local) = sorted.first() {
used_removed.insert(ri_local);
used_added.insert(ai_local);
let old_sym = removed_tokens[ri_local].1;
let new_sym = added_tokens[ai_local].1;
tracing::debug!(
old = %old_sym.name,
new = %new_sym.name,
value = %old_value,
"Token matched by CSS value"
);
matches.push(RenameMatch {
old: old_sym,
new: new_sym,
});
value_matches += 1;
}
}
}
if value_matches > 0 {
tracing::info!(
value_matches,
"Additional tokens matched by CSS value fallback"
);
}
}
tracing::info!(
jaccard_matched,
total_matched = matches.len(),
removed = removed_tokens.len(),
added = added_tokens.len(),
"Token rename detection complete"
);
matches
}
fn tokenize_name(name: &str) -> BTreeSet<String> {
name.split('_')
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase())
.collect()
}
fn extract_token_value(symbol: &Symbol) -> Option<String> {
let return_type = symbol.signature.as_ref()?.return_type.as_deref()?;
let value_start = return_type
.find("[\"value\"]")
.or_else(|| return_type.find("\"value\""))?;
let after_key = &return_type[value_start..];
let colon_pos = after_key.find(':')?;
let after_colon = &after_key[colon_pos + 1..];
let open_quote = after_colon.find('"')?;
let after_open = &after_colon[open_quote + 1..];
let close_quote = after_open.find('"')?;
let value = after_open[..close_quote].to_string();
if value.is_empty() {
None
} else {
Some(value)
}
}
#[cfg(test)]
mod token_tests {
use super::*;
use crate::types::{Signature, Symbol, SymbolKind, Visibility};
use std::path::PathBuf;
fn make_token(name: &str, package: &str) -> Symbol {
Symbol {
name: name.to_string(),
qualified_name: format!("{}/{}.{}", package, name, name),
kind: SymbolKind::Constant,
visibility: Visibility::Public,
file: PathBuf::from(format!("{}/{}.d.ts", package, name)),
package: Some(package.to_string()),
import_path: None,
line: 1,
signature: None,
extends: None,
implements: vec![],
is_abstract: false,
type_dependencies: vec![],
is_readonly: false,
is_static: false,
accessor_kind: None,
members: vec![],
rendered_components: vec![],
css: vec![],
}
}
#[test]
fn test_tokenize_name() {
let segs = tokenize_name("global_Color_dark_100");
assert!(segs.contains("global"));
assert!(segs.contains("color")); assert!(segs.contains("dark"));
assert!(segs.contains("100"));
assert_eq!(segs.len(), 4);
}
#[test]
fn test_token_rename_basic() {
let old = make_token("global_Color_dark_100", "@patternfly/react-tokens");
let new = make_token("t_color_dark_100", "@patternfly/react-tokens");
let removed = vec![&old];
let added = vec![&new];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].old.name, "global_Color_dark_100");
assert_eq!(matches[0].new.name, "t_color_dark_100");
}
#[test]
fn test_token_rename_chart_prefix() {
let old = make_token("global_success_color_100", "@patternfly/react-tokens");
let new = make_token(
"t_chart_global_success_color_100",
"@patternfly/react-tokens",
);
let removed = vec![&old];
let added = vec![&new];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].new.name, "t_chart_global_success_color_100");
}
#[test]
fn test_token_rename_no_false_positive() {
let old = make_token("global_Color_dark_100", "@patternfly/react-tokens");
let new = make_token("c_button_FontSize_100", "@patternfly/react-tokens");
let removed = vec![&old];
let added = vec![&new];
let matches = detect_token_renames(&removed, &added);
assert!(matches.is_empty(), "Should not match unrelated tokens");
}
#[test]
fn test_token_rename_greedy_best_match() {
let old1 = make_token("global_Color_dark_100", "@patternfly/react-tokens");
let old2 = make_token("global_Color_dark_200", "@patternfly/react-tokens");
let new1 = make_token("t_color_dark_100", "@patternfly/react-tokens");
let new2 = make_token("t_color_dark_200", "@patternfly/react-tokens");
let removed = vec![&old1, &old2];
let added = vec![&new1, &new2];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 2);
let match_map: HashMap<&str, &str> = matches
.iter()
.map(|m| (m.old.name.as_str(), m.new.name.as_str()))
.collect();
assert_eq!(
match_map.get("global_Color_dark_100"),
Some(&"t_color_dark_100")
);
assert_eq!(
match_map.get("global_Color_dark_200"),
Some(&"t_color_dark_200")
);
}
#[test]
fn test_token_rename_many_to_one_resolved() {
let old1 = make_token("global_Color_dark_100", "@patternfly/react-tokens");
let old2 = make_token(
"global_BackgroundColor_dark_100",
"@patternfly/react-tokens",
);
let new = make_token("t_color_dark_100", "@patternfly/react-tokens");
let removed = vec![&old1, &old2];
let added = vec![&new];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].old.name, "global_Color_dark_100");
}
#[test]
fn test_token_rename_skips_non_constants() {
let old = Symbol {
kind: SymbolKind::Interface,
..make_token("ModalProps", "@patternfly/react-core")
};
let new = Symbol {
kind: SymbolKind::Interface,
..make_token("ContentProps", "@patternfly/react-core")
};
let removed = vec![&old];
let added = vec![&new];
let matches = detect_token_renames(&removed, &added);
assert!(matches.is_empty());
}
#[test]
fn test_token_rename_case_insensitive() {
let old = make_token(
"global_BackgroundColor_dark_100",
"@patternfly/react-tokens",
);
let new = make_token("t_backgroundcolor_dark_100", "@patternfly/react-tokens");
let removed = vec![&old];
let added = vec![&new];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 1);
}
fn make_token_with_value(name: &str, package: &str, css_name: &str, css_value: &str) -> Symbol {
let mut sym = make_token(name, package);
sym.signature = Some(Signature {
parameters: Vec::new(),
return_type: Some(format!(
"{{ [\"name\"]: \"{}\"; [\"value\"]: \"{}\"; [\"var\"]: \"var({})\" }}",
css_name, css_value, css_name,
)),
type_parameters: Vec::new(),
is_async: false,
});
sym
}
#[test]
fn test_extract_token_value_basic() {
let sym = make_token_with_value(
"global_Color_dark_100",
"@patternfly/react-tokens",
"--pf-v5-global--Color--dark-100",
"#151515",
);
assert_eq!(extract_token_value(&sym), Some("#151515".to_string()));
}
#[test]
fn test_extract_token_value_calc() {
let sym = make_token_with_value(
"c_button_Width",
"@patternfly/react-tokens",
"--pf-v5-c-button--Width",
"calc(1.25rem * 2)",
);
assert_eq!(
extract_token_value(&sym),
Some("calc(1.25rem * 2)".to_string())
);
}
#[test]
fn test_extract_token_value_no_signature() {
let sym = make_token("global_Color_dark_100", "@patternfly/react-tokens");
assert_eq!(extract_token_value(&sym), None);
}
#[test]
fn test_extract_token_value_no_value_field() {
let mut sym = make_token("foo", "@patternfly/react-tokens");
sym.signature = Some(Signature {
parameters: Vec::new(),
return_type: Some("string".to_string()),
type_parameters: Vec::new(),
is_async: false,
});
assert_eq!(extract_token_value(&sym), None);
}
#[test]
fn test_value_fallback_matches_when_name_doesnt() {
let old = make_token_with_value(
"global_Color_dark_100",
"@patternfly/react-tokens",
"--pf-v5-global--Color--dark-100",
"#151515",
);
let new = make_token_with_value(
"t_global_background_color_primary_default",
"@patternfly/react-tokens",
"--pf-t--global--background--color--primary--default",
"#151515",
);
let removed = vec![&old];
let added = vec![&new];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 1, "Should match by value when names diverge");
assert_eq!(matches[0].old.name, "global_Color_dark_100");
assert_eq!(
matches[0].new.name,
"t_global_background_color_primary_default"
);
}
#[test]
fn test_value_fallback_picks_best_segment_overlap() {
let old = make_token_with_value(
"global_spacer_xl",
"@patternfly/react-tokens",
"--pf-v5-global--spacer--xl",
"2rem",
);
let new1 = make_token_with_value(
"t_global_spacer_xl",
"@patternfly/react-tokens",
"--pf-t--global--spacer--xl",
"2rem",
);
let new2 = make_token_with_value(
"t_layout_padding_horizontal",
"@patternfly/react-tokens",
"--pf-t--layout--padding--horizontal",
"2rem",
);
let removed = vec![&old];
let added = vec![&new1, &new2];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 1, "Should match to the best candidate");
assert_eq!(
matches[0].new.name, "t_global_spacer_xl",
"Should prefer the candidate with more segment overlap"
);
}
#[test]
fn test_value_fallback_doesnt_override_jaccard() {
let old = make_token_with_value(
"global_Color_dark_100",
"@patternfly/react-tokens",
"--pf-v5-global--Color--dark-100",
"#151515",
);
let new_jaccard = make_token_with_value(
"t_color_dark_100",
"@patternfly/react-tokens",
"--pf-t--color--dark--100",
"#222222", );
let new_value = make_token_with_value(
"t_something_completely_different",
"@patternfly/react-tokens",
"--pf-t--something",
"#151515", );
let removed = vec![&old];
let added = vec![&new_jaccard, &new_value];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].new.name, "t_color_dark_100");
}
#[test]
fn test_value_fallback_exclusive() {
let old_component = make_token_with_value(
"c_accordion_toggle_Color",
"@patternfly/react-tokens",
"--pf-v5-c-accordion--toggle--Color",
"#151515",
);
let old_global = make_token_with_value(
"global_Color_dark_100",
"@patternfly/react-tokens",
"--pf-v5-global--Color--dark-100",
"#151515",
);
let new_token = make_token_with_value(
"t_global_text_color_regular",
"@patternfly/react-tokens",
"--pf-t--global--text--color--regular",
"#151515",
);
let removed = vec![&old_component, &old_global];
let added = vec![&new_token];
let matches = detect_token_renames(&removed, &added);
assert_eq!(
matches.len(),
1,
"Only one removed token should match (exclusive). Got: {:?}",
matches.iter().map(|m| &m.old.name).collect::<Vec<_>>()
);
}
#[test]
fn test_value_fallback_prefers_segment_overlap() {
let old1 = make_token_with_value(
"global_spacer_md",
"@patternfly/react-tokens",
"--pf-v5-global--spacer--md",
"1rem",
);
let old2 = make_token_with_value(
"c_button_padding",
"@patternfly/react-tokens",
"--pf-v5-c-button--padding",
"1rem",
);
let new_global = make_token_with_value(
"t_global_spacer_md",
"@patternfly/react-tokens",
"--pf-t--global--spacer--md",
"1rem",
);
let new_component = make_token_with_value(
"c_button_PaddingInline",
"@patternfly/react-tokens",
"--pf-v6-c-button--PaddingInline",
"1rem",
);
let removed = vec![&old1, &old2];
let added = vec![&new_global, &new_component];
let matches = detect_token_renames(&removed, &added);
assert_eq!(matches.len(), 2);
let global_match = matches.iter().find(|m| m.old.name == "global_spacer_md");
assert!(global_match.is_some());
assert_eq!(global_match.unwrap().new.name, "t_global_spacer_md");
}
#[derive(serde::Deserialize)]
struct FixtureSymbol {
kind: String,
name: String,
return_type: Option<String>,
}
#[derive(serde::Deserialize)]
struct ExpectedRename {
old: String,
new: String,
}
#[derive(serde::Deserialize)]
struct TokenSurfaceFixture {
old_symbols: Vec<FixtureSymbol>,
new_symbols: Vec<FixtureSymbol>,
expected_renames: Vec<ExpectedRename>,
}
fn fixture_to_symbol(fs: &FixtureSymbol, pkg: &str) -> Symbol {
let kind = match fs.kind.as_str() {
"constant" => SymbolKind::Constant,
"variable" => SymbolKind::Variable,
_ => SymbolKind::Constant,
};
let signature = fs.return_type.as_ref().map(|rt| Signature {
parameters: vec![],
return_type: Some(rt.clone()),
type_parameters: vec![],
is_async: false,
});
Symbol {
name: fs.name.clone(),
qualified_name: format!("{}.{}", pkg, fs.name),
kind,
visibility: Visibility::Public,
file: PathBuf::from(format!("packages/react-tokens/src/{}.d.ts", fs.name)),
package: Some(pkg.to_string()),
import_path: None,
line: 1,
signature,
extends: None,
implements: vec![],
is_abstract: false,
type_dependencies: vec![],
is_readonly: false,
is_static: false,
accessor_kind: None,
members: vec![],
rendered_components: vec![],
css: vec![],
}
}
#[test]
fn test_full_patternfly_token_rename_detection() {
let fixture_data = include_str!("../../tests/fixtures/token_surfaces.json");
let fixture: TokenSurfaceFixture =
serde_json::from_str(fixture_data).expect("failed to parse token_surfaces.json");
let pkg = "@patternfly/react-tokens";
let old_symbols: Vec<Symbol> = fixture
.old_symbols
.iter()
.map(|fs| fixture_to_symbol(fs, pkg))
.collect();
let new_symbols: Vec<Symbol> = fixture
.new_symbols
.iter()
.map(|fs| fixture_to_symbol(fs, pkg))
.collect();
assert!(
old_symbols.len() > 3500,
"Expected 3500+ old symbols, got {}",
old_symbols.len()
);
assert!(
new_symbols.len() > 2000,
"Expected 2000+ new symbols, got {}",
new_symbols.len()
);
let old_refs: Vec<&Symbol> = old_symbols.iter().collect();
let new_refs: Vec<&Symbol> = new_symbols.iter().collect();
let matches = detect_token_renames(&old_refs, &new_refs);
assert!(
matches.len() > 1000,
"Expected 1000+ rename matches, got {}",
matches.len()
);
for m in &matches {
assert!(
!m.old.name.contains("variable: ") && !m.old.name.contains("constant: "),
"Old name is a symbol_summary: {}",
m.old.name
);
assert!(
!m.new.name.contains("variable: ") && !m.new.name.contains("constant: "),
"New name is a symbol_summary: {}",
m.new.name
);
}
let expected_map: HashMap<String, String> = fixture
.expected_renames
.iter()
.map(|e| (e.old.clone(), e.new.clone()))
.collect();
let match_map: HashMap<String, String> = matches
.iter()
.map(|m| (m.old.name.clone(), m.new.name.clone()))
.collect();
let mut correct = 0usize;
let mut wrong = 0usize;
let mut missing = 0usize;
for (old_name, expected_new) in &expected_map {
match match_map.get(old_name) {
Some(actual_new) if actual_new == expected_new => correct += 1,
Some(_) => wrong += 1,
None => missing += 1,
}
}
let total_expected = expected_map.len();
let accuracy = if total_expected > 0 {
correct as f64 / total_expected as f64
} else {
0.0
};
eprintln!(
"Token rename detection results:\n \
Total matches: {}\n \
Expected renames: {}\n \
Correct: {} ({:.1}%)\n \
Wrong target: {}\n \
Not found: {}",
matches.len(),
total_expected,
correct,
accuracy * 100.0,
wrong,
missing,
);
assert!(
accuracy >= 0.40,
"Accuracy regressed: {:.1}% ({} / {}), baseline is ~42%",
accuracy * 100.0,
correct,
total_expected
);
let critical_tokens = [
(
"global_success_color_100",
"t_chart_global_success_color_100",
),
(
"global_warning_color_100",
"t_chart_global_warning_color_100",
),
("global_danger_color_100", "t_chart_global_danger_color_100"),
];
for (old_name, expected_new) in &critical_tokens {
if let Some(actual_new) = match_map.get(*old_name) {
assert!(
!actual_new.is_empty(),
"{} matched to empty string",
old_name
);
eprintln!(
" {} → {} (expected {})",
old_name, actual_new, expected_new
);
}
}
}
fn make_prop(name: &str, parent: &str, return_type: &str) -> Symbol {
Symbol {
name: name.to_string(),
qualified_name: format!("{}.{}", parent, name),
kind: SymbolKind::Property,
visibility: Visibility::Public,
file: PathBuf::from("test.d.ts"),
package: Some("@test/pkg".to_string()),
import_path: None,
line: 1,
signature: Some(Signature {
return_type: Some(return_type.to_string()),
parameters: vec![],
is_async: false,
type_parameters: vec![],
}),
extends: None,
implements: vec![],
is_abstract: false,
type_dependencies: vec![],
is_readonly: false,
is_static: false,
accessor_kind: None,
members: vec![],
rendered_components: vec![],
css: vec![],
}
}
#[test]
fn test_pass4_name_similarity_same_interface() {
let removed = vec![make_prop(
"splitButtonOptions",
"MenuToggle.MenuToggleProps",
"SplitButtonOptions",
)];
let added = vec![make_prop(
"splitButtonItems",
"MenuToggle.MenuToggleProps",
"ReactNode[]",
)];
let removed_refs: Vec<&Symbol> = removed.iter().collect();
let added_refs: Vec<&Symbol> = added.iter().collect();
let matches = detect_renames(&removed_refs, &added_refs);
assert_eq!(matches.len(), 1, "Should match via Pass 4 name similarity");
assert_eq!(matches[0].old.name, "splitButtonOptions");
assert_eq!(matches[0].new.name, "splitButtonItems");
}
#[test]
fn test_pass4_rejects_low_similarity() {
let removed = vec![make_prop("isOpen", "Dropdown.DropdownProps", "boolean")];
let added = vec![make_prop("isDisabled", "Dropdown.DropdownProps", "string")];
let removed_refs: Vec<&Symbol> = removed.iter().collect();
let added_refs: Vec<&Symbol> = added.iter().collect();
let matches = detect_renames(&removed_refs, &added_refs);
assert!(
matches.is_empty(),
"Should not match props with low name similarity"
);
}
#[test]
fn test_pass4_different_interfaces_no_match() {
let removed = vec![make_prop(
"splitButtonOptions",
"MenuToggle.MenuToggleProps",
"SplitButtonOptions",
)];
let added = vec![make_prop(
"splitButtonItems",
"Button.ButtonProps",
"ReactNode[]",
)];
let removed_refs: Vec<&Symbol> = removed.iter().collect();
let added_refs: Vec<&Symbol> = added.iter().collect();
let matches = detect_renames(&removed_refs, &added_refs);
assert!(
matches.is_empty(),
"Should not match props from different interfaces"
);
}
}
pub(super) fn name_similarity(a: &str, b: &str) -> f64 {
if a == b {
return 1.0;
}
if a.is_empty() || b.is_empty() {
return 0.0;
}
let lcs_len = longest_common_subsequence_len(a, b);
let max_len = a.len().max(b.len());
lcs_len as f64 / max_len as f64
}
pub(super) fn longest_common_subsequence_len(a: &str, b: &str) -> usize {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let m = a_bytes.len();
let n = b_bytes.len();
let mut prev = vec![0usize; n + 1];
let mut curr = vec![0usize; n + 1];
for i in 1..=m {
for j in 1..=n {
if a_bytes[i - 1] == b_bytes[j - 1] {
curr[j] = prev[j - 1] + 1;
} else {
curr[j] = prev[j].max(curr[j - 1]);
}
}
std::mem::swap(&mut prev, &mut curr);
curr.iter_mut().for_each(|v| *v = 0);
}
prev[n]
}