mod generated;
use generated::{ClassInfo, CLASSES, DEPRECATED};
use std::collections::HashMap;
use std::sync::OnceLock;
fn index() -> &'static HashMap<&'static str, &'static ClassInfo> {
static INDEX: OnceLock<HashMap<&'static str, &'static ClassInfo>> = OnceLock::new();
INDEX.get_or_init(|| CLASSES.iter().map(|class| (class.name, class)).collect())
}
fn lookup(name: &str) -> Option<&'static ClassInfo> {
index().get(name).copied()
}
fn ancestry(name: &str) -> impl Iterator<Item = &'static ClassInfo> {
let mut current = lookup(name);
std::iter::from_fn(move || {
let class = current?;
current = lookup(class.superclass);
Some(class)
})
}
pub fn is_deprecated(member: &str) -> bool {
DEPRECATED.binary_search(&member).is_ok()
}
pub fn is_class(name: &str) -> bool {
lookup(name).is_some_and(|class| class.creatable)
}
pub fn has_property(class: &str, property: &str) -> bool {
ancestry(class).any(|info| info.properties.contains(&property))
}
pub fn is_event(class: &str, name: &str) -> bool {
ancestry(class).any(|info| info.events.contains(&name))
}
pub fn has_text_property(name: &str) -> bool {
has_property(name, "Text")
}
pub fn creatable_classes() -> impl Iterator<Item = &'static str> {
CLASSES
.iter()
.filter(|class| class.creatable)
.map(|class| class.name)
}
pub fn properties(class: &str) -> impl Iterator<Item = &'static str> {
ancestry(class).flat_map(|info| info.properties.iter().copied())
}
pub fn events(class: &str) -> impl Iterator<Item = &'static str> {
ancestry(class).flat_map(|info| info.events.iter().copied())
}
pub fn superclass(class: &str) -> Option<&'static str> {
let info = lookup(class)?;
lookup(info.superclass).map(|parent| parent.name)
}
pub fn is_member_name(name: &str) -> bool {
CLASSES
.iter()
.any(|class| class.properties.contains(&name) || class.events.contains(&name))
}
pub fn closest_member_anywhere(name: &str) -> Option<&'static str> {
let needle = name.to_lowercase();
CLASSES
.iter()
.flat_map(|class| class.properties.iter().chain(class.events.iter()))
.map(|member| (*member, edit_distance(&needle, &member.to_lowercase())))
.filter(|(candidate, distance)| {
let budget = (candidate.len().min(name.len()) / 4).clamp(1, 3);
*distance <= budget
})
.min_by_key(|(candidate, distance)| (*distance, candidate.len(), *candidate))
.map(|(candidate, _)| candidate)
}
pub fn closest_class(name: &str) -> Option<&'static str> {
let needle = name.to_lowercase();
CLASSES
.iter()
.filter(|class| class.creatable)
.map(|class| {
(
class.name,
edit_distance(&needle, &class.name.to_lowercase()),
)
})
.filter(|(candidate, distance)| {
let budget = (candidate.len().min(name.len()) / 4).clamp(1, 3);
*distance <= budget
})
.min_by_key(|(candidate, distance)| (*distance, candidate.len()))
.map(|(candidate, _)| candidate)
}
pub fn closest_members(class: &str, name: &str) -> Vec<&'static str> {
const LIMIT: usize = 3;
let needle = name.to_lowercase();
let members =
|| ancestry(class).flat_map(|info| info.properties.iter().chain(info.events.iter()));
let mut contained: Vec<&'static str> = members()
.filter(|member| member.to_lowercase().contains(&needle))
.copied()
.collect();
if !contained.is_empty() {
contained.sort_by_key(|member| (member.len(), *member));
contained.dedup();
contained.truncate(LIMIT);
return contained;
}
let mut scored: Vec<(&'static str, usize)> = members()
.map(|member| (*member, edit_distance(&needle, &member.to_lowercase())))
.filter(|(candidate, distance)| {
let budget = (candidate.len().min(name.len()) / 4).clamp(1, 3);
*distance <= budget
})
.collect();
scored.sort_by_key(|(candidate, distance)| (*distance, candidate.len(), *candidate));
scored.truncate(LIMIT);
scored.into_iter().map(|(candidate, _)| candidate).collect()
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut distance = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for (i, row) in distance.iter_mut().enumerate() {
row[0] = i;
}
for (j, cell) in distance[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
distance[i][j] = (distance[i - 1][j] + 1)
.min(distance[i][j - 1] + 1)
.min(distance[i - 1][j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
distance[i][j] = distance[i][j].min(distance[i - 2][j - 2] + 1);
}
}
}
distance[a.len()][b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_creatable_classes() {
assert!(is_class("Frame"));
assert!(is_class("UICorner"));
assert!(is_class("ScrollingFrame"));
assert!(is_class("Folder"));
assert!(!is_class("Receipt"));
}
#[test]
fn services_and_abstract_classes_are_not_intrinsics() {
assert!(!is_class("Players"));
assert!(!is_class("GuiObject"));
assert!(!is_class("Instance"));
}
#[test]
fn resolves_properties_through_inheritance() {
assert!(has_property("TextLabel", "Text"));
assert!(has_property("TextLabel", "BackgroundColor3"));
assert!(has_property("Frame", "BackgroundColor3"));
assert!(has_property("Frame", "Name"));
assert!(!has_property("Frame", "Text"));
assert!(!has_property("Frame", "Nonsense"));
}
#[test]
fn read_only_properties_are_not_settable() {
assert!(!has_property("TextLabel", "ContentText"));
}
#[test]
fn resolves_events_through_inheritance() {
assert!(is_event("TextButton", "Activated"));
assert!(is_event("TextButton", "MouseButton1Click"));
assert!(!is_event("Frame", "Activated"));
assert!(!has_property("TextButton", "Activated"));
assert!(!is_event("TextLabel", "Text"));
}
#[test]
fn enumerates_creatable_classes_only() {
let classes: Vec<&str> = creatable_classes().collect();
assert!(classes.contains(&"Frame"));
assert!(classes.contains(&"TextLabel"));
assert!(!classes.contains(&"Players"));
assert!(!classes.contains(&"GuiObject"));
}
#[test]
fn enumerates_members_through_inheritance() {
let properties: Vec<&str> = properties("TextLabel").collect();
assert!(properties.contains(&"Text"));
assert!(properties.contains(&"BackgroundColor3"));
assert!(properties.contains(&"Name"));
assert!(!properties.contains(&"ContentText"));
let events: Vec<&str> = events("TextButton").collect();
assert!(events.contains(&"Activated"));
assert!(!events.contains(&"Text"));
}
#[test]
fn enumeration_agrees_with_the_by_name_queries() {
for property in properties("TextLabel") {
assert!(has_property("TextLabel", property), "{property}");
}
for event in events("TextButton") {
assert!(is_event("TextButton", event), "{event}");
}
for class in creatable_classes() {
assert!(is_class(class), "{class}");
}
}
#[test]
fn walks_the_superclass_chain() {
assert_eq!(superclass("TextLabel"), Some("GuiLabel"));
assert_eq!(superclass("Instance"), Some("Object"));
assert_eq!(superclass("Object"), None);
assert_eq!(superclass("NotAClass"), None);
}
#[test]
fn knows_which_classes_carry_text() {
assert!(has_text_property("TextLabel"));
assert!(has_text_property("TextButton"));
assert!(has_text_property("TextBox"));
assert!(!has_text_property("Frame"));
assert!(!has_text_property("ImageLabel"));
}
#[test]
fn suggests_a_near_miss() {
assert_eq!(closest_class("TextLabl"), Some("TextLabel"));
assert_eq!(closest_class("Frmae"), Some("Frame"));
assert_eq!(closest_class("ScrollingFrmae"), Some("ScrollingFrame"));
}
#[test]
fn does_not_suggest_for_unrelated_names() {
assert_eq!(closest_class("Receipt"), None);
assert_eq!(closest_class("Row"), None);
assert_eq!(closest_class("Card"), None);
}
#[test]
fn suggestions_are_case_insensitive() {
assert_eq!(closest_class("textlabel"), Some("TextLabel"));
assert_eq!(closest_class("frame"), Some("Frame"));
}
#[test]
fn suggests_members_by_containment_first() {
let suggestions = closest_members("Frame", "Color3");
assert!(suggestions.contains(&"BackgroundColor3"), "{suggestions:?}");
assert!(suggestions.contains(&"BorderColor3"), "{suggestions:?}");
}
#[test]
fn suggests_members_by_edit_distance() {
assert_eq!(closest_members("TextLabel", "Txt").first(), Some(&"Text"));
assert_eq!(closest_members("TextLabel", "Tetx").first(), Some(&"Text"));
assert_eq!(
closest_members("TextButton", "Activate").first(),
Some(&"Activated")
);
}
#[test]
fn suggests_nothing_for_unrelated_attributes() {
assert!(closest_members("Frame", "Zzzzq").is_empty());
}
#[test]
fn suggestions_are_capped() {
assert!(closest_members("TextLabel", "Text").len() <= 3);
}
#[test]
fn transpositions_count_as_one_slip() {
assert_eq!(edit_distance("frmae", "frame"), 1);
assert_eq!(edit_distance("textlabl", "textlabel"), 1);
}
}