#[cfg(test)]
use crate::model::BehaviorEventRef;
use crate::model::StepDefinition;
const SHORT_MATCHER_MAX_CHARS: usize = 12;
const SHORT_SIMILARITY_GATE: f64 = 0.92;
const LONG_SIMILARITY_GATE: f64 = 0.90;
fn matcher_comparison_text(definition: &StepDefinition) -> &str {
&definition.normalized_matcher
}
pub(super) fn matcher_similarity(left: &StepDefinition, right: &StepDefinition) -> f64 {
let left = matcher_comparison_text(left);
let right = matcher_comparison_text(right);
let max_length = left.chars().count().max(right.chars().count());
if max_length == 0 {
return 1.0;
}
let distance = strsim::levenshtein(left, right);
let edit_similarity = 1.0 - distance as f64 / max_length as f64;
edit_similarity.max(strsim::jaro_winkler(left, right))
}
pub(super) fn is_near_matcher(
left: &StepDefinition,
right: &StepDefinition,
similarity: f64,
) -> bool {
if !matchers_compatible(left, right) {
return false;
}
let length = matcher_comparison_text(left)
.chars()
.count()
.max(matcher_comparison_text(right).chars().count());
let similarity_gate = if length <= SHORT_MATCHER_MAX_CHARS {
SHORT_SIMILARITY_GATE
} else {
LONG_SIMILARITY_GATE
};
similarity >= similarity_gate
}
const PARAMETERIZATION_SIMILARITY_GATE: f64 = 0.6;
const MAX_PARAMETERIZED_CHANGE_WORDS: usize = 3;
pub(super) fn is_parameterizable_matcher(
left: &StepDefinition,
right: &StepDefinition,
similarity: f64,
) -> bool {
matchers_compatible(left, right)
&& similarity >= PARAMETERIZATION_SIMILARITY_GATE
&& matcher_change_word_span(&left.normalized_matcher, &right.normalized_matcher)
<= MAX_PARAMETERIZED_CHANGE_WORDS
}
fn matchers_compatible(left: &StepDefinition, right: &StepDefinition) -> bool {
left.matcher_kind == right.matcher_kind
&& left
.analysis_profile()
.dialect
.similarity_flags_match(&left.matcher_flags, &right.matcher_flags)
&& !has_polarity_conflict(
matcher_comparison_text(left),
matcher_comparison_text(right),
)
}
fn matcher_change_word_span(left: &str, right: &str) -> usize {
let left = words(left);
let right = words(right);
let min = left.len().min(right.len());
let mut prefix = 0;
while prefix < min && left[prefix] == right[prefix] {
prefix += 1;
}
let mut suffix = 0;
while suffix < min - prefix && left[left.len() - 1 - suffix] == right[right.len() - 1 - suffix]
{
suffix += 1;
}
let left_change = &left[prefix..left.len() - suffix];
let right_change = &right[prefix..right.len() - suffix];
if splits_into_separate_changes(left_change, right_change) {
return usize::MAX;
}
left_change.len().max(right_change.len())
}
fn splits_into_separate_changes(left: &[String], right: &[String]) -> bool {
let flanked = |region: &[String], word: &String| {
region
.iter()
.enumerate()
.any(|(index, candidate)| candidate == word && index > 0 && index + 1 < region.len())
};
left.iter()
.any(|word| right.contains(word) && (flanked(left, word) || flanked(right, word)))
}
fn has_polarity_conflict(left: &str, right: &str) -> bool {
let left = words(left);
let right = words(right);
const NEGATIONS: [&str; 8] = [
"not", "no", "never", "cannot", "cant", "without", "unable", "neither",
];
let left_negative = left.iter().any(|word| NEGATIONS.contains(&word.as_str()));
let right_negative = right.iter().any(|word| NEGATIONS.contains(&word.as_str()));
if left_negative != right_negative {
return true;
}
left.iter().any(|left_word| {
right.iter().any(|right_word| {
prefixed_opposites(left_word, right_word) || prefixed_opposites(right_word, left_word)
})
})
}
fn words(value: &str) -> Vec<String> {
value
.split(|character: char| !character.is_alphanumeric())
.filter(|word| !word.is_empty())
.map(str::to_lowercase)
.collect()
}
fn prefixed_opposites(positive: &str, negative: &str) -> bool {
matches!(
(positive, negative),
("enable", "disable")
| ("enables", "disables")
| ("enabled", "disabled")
| ("enabling", "disabling")
) || positive.len() >= 4
&& ["in", "un", "non", "dis", "il", "im", "ir"]
.iter()
.any(|prefix| negative.strip_prefix(prefix) == Some(positive))
}
#[cfg(test)]
pub(super) fn handler_similarity(left: &StepDefinition, right: &StepDefinition) -> f64 {
if !handler_runtime_compatible(left, right) {
return 0.0;
}
let left_events = executable_behavior_events(left);
let right_events = executable_behavior_events(right);
handler_similarity_with_relationship(
left.handler.alpha_normalized == right.handler.alpha_normalized,
left.handler.structural == right.handler.structural
&& left.handler.behavior_signature == right.handler.behavior_signature,
event_similarity_available(left, right),
&left_events,
&right_events,
)
}
pub(super) fn event_similarity_available(left: &StepDefinition, right: &StepDefinition) -> bool {
left.analysis_profile().event_similarity && right.analysis_profile().event_similarity
}
pub(super) fn handler_runtime_compatible(left: &StepDefinition, right: &StepDefinition) -> bool {
use crate::source_adapter::semantics::CaptureContext;
if (left.capture_context() == CaptureContext::LexicalFile
|| right.capture_context() == CaptureContext::LexicalFile)
&& left.location.path != right.location.path
{
return false;
}
if left.analysis_profile().handler_domain != right.analysis_profile().handler_domain {
return false;
}
match (left.method_semantics(), right.method_semantics()) {
(Some(left), Some(right)) => left == right,
_ => true,
}
}
#[cfg(test)]
fn executable_behavior_events(definition: &StepDefinition) -> Vec<BehaviorEventRef<'_>> {
definition
.handler
.behavior_signature
.iter()
.map(|event| BehaviorEventRef::from_legacy(event))
.filter(|event| !event.is_method())
.collect()
}
pub(super) fn handler_similarity_with_relationship<T: Eq>(
same_alpha: bool,
same_structural: bool,
event_similarity: bool,
left_events: &[T],
right_events: &[T],
) -> f64 {
if same_alpha && left_events == right_events {
return 1.0;
}
if same_structural {
return 0.95;
}
if !event_similarity {
return 0.0;
}
let max_len = left_events.len().max(right_events.len());
if max_len == 0 {
return 0.0;
}
ordered_common_subsequence_len(left_events, right_events) as f64 / max_len as f64
}
pub(super) fn ordered_common_subsequence_len<T: Eq>(left: &[T], right: &[T]) -> usize {
let mut previous = vec![0; right.len() + 1];
for left_event in left {
let mut current = vec![0; right.len() + 1];
for (index, right_event) in right.iter().enumerate() {
current[index + 1] = if left_event == right_event {
previous[index] + 1
} else {
current[index].max(previous[index + 1])
};
}
previous = current;
}
previous[right.len()]
}
pub(super) fn round_score(value: f64) -> f64 {
(value * 1000.0).round() / 1000.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
Framework, HandlerFingerprint, MatcherKind, SourceLocation, StepDefinition,
};
fn definition(matcher: &str, kind: MatcherKind) -> StepDefinition {
StepDefinition {
matcher: matcher.to_owned(),
normalized_matcher: matcher.to_owned(),
matcher_kind: kind,
matcher_flags: String::new(),
handler: HandlerFingerprint {
exact: String::new(),
normalized: String::new(),
alpha_normalized: String::new(),
structural: String::new(),
behavior_signature: Vec::new(),
source_snippet: String::new(),
comparable: true,
trivial: false,
},
framework: Framework::Unknown,
registration: "Given".to_owned(),
location: SourceLocation::new("steps.ts", 1, 1, 1, 1),
inline_suppressions: Vec::new(),
}
}
#[test]
fn matcher_gate_rejects_mixed_syntax_and_polarity_conflicts() {
let expression = definition("account is active", MatcherKind::CucumberExpression);
let regex = definition("account is active", MatcherKind::RegularExpression);
assert!(!is_near_matcher(&expression, ®ex, 1.0));
let inactive = definition("account is inactive", MatcherKind::CucumberExpression);
assert!(!is_near_matcher(&expression, &inactive, 1.0));
let not_active = definition("account is not active", MatcherKind::CucumberExpression);
assert!(!is_near_matcher(&expression, ¬_active, 1.0));
}
#[test]
fn similarity_helpers_cover_empty_exact_structural_and_lcs_paths() {
let empty = definition("", MatcherKind::CucumberExpression);
assert_eq!(matcher_similarity(&empty, &empty), 1.0);
assert_eq!(
handler_similarity_with_relationship::<u8>(true, false, true, &[], &[]),
1.0
);
assert_eq!(
handler_similarity_with_relationship::<u8>(false, true, true, &[], &[]),
0.95
);
assert_eq!(
handler_similarity_with_relationship::<u8>(false, false, true, &[], &[]),
0.0
);
for available in [false, true] {
for (alpha, structural, expected) in [
(true, false, 1.0),
(false, true, 0.95),
(false, false, if available { 0.5 } else { 0.0 }),
] {
let right = if alpha { &[1, 2][..] } else { &[1, 3][..] };
assert_eq!(
handler_similarity_with_relationship(
alpha,
structural,
available,
&[1, 2],
right
),
expected
);
}
}
assert_eq!(ordered_common_subsequence_len(&[1, 2, 3], &[2, 3, 4]), 2);
assert_eq!(round_score(0.123_6), 0.124);
}
#[test]
fn method_metadata_is_a_compatibility_gate_not_similarity_evidence() {
let mut left = definition("left", MatcherKind::CucumberExpression);
left.handler.alpha_normalized = "left-alpha".to_owned();
left.handler.structural = "left-structure".to_owned();
left.handler.behavior_signature = vec![
"method:instance sync".to_owned(),
"assert:expect#toBe:subject:ready".to_owned(),
];
let mut conflicting_assertion = left.clone();
conflicting_assertion.handler.alpha_normalized = "right-alpha".to_owned();
conflicting_assertion.handler.structural = "right-structure".to_owned();
conflicting_assertion.handler.behavior_signature[1] =
"assert:expect#toBe:subject:idle".to_owned();
assert_eq!(handler_similarity(&left, &conflicting_assertion), 0.0);
let mut incompatible_method = left.clone();
incompatible_method.handler.alpha_normalized = "async-alpha".to_owned();
incompatible_method.handler.structural = "async-structure".to_owned();
incompatible_method.handler.behavior_signature[0] = "method:async".to_owned();
assert_eq!(handler_similarity(&left, &incompatible_method), 0.0);
let mut function_handler = left.clone();
function_handler.handler.alpha_normalized = "function-alpha".to_owned();
function_handler.handler.structural = "function-structure".to_owned();
function_handler.handler.behavior_signature.remove(0);
assert_eq!(handler_similarity(&left, &function_handler), 1.0);
}
}