use std::collections::BTreeSet;
use super::Maintenance;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Category {
StateManagement,
Ui,
DataProfile,
Testing,
Utility,
}
impl Category {
pub fn label(&self) -> &'static str {
match self {
Category::StateManagement => "State management",
Category::Ui => "UI",
Category::DataProfile => "Data & profiles",
Category::Testing => "Testing",
Category::Utility => "Utilities",
}
}
pub const ALL: [Category; 5] = [
Category::StateManagement,
Category::Ui,
Category::DataProfile,
Category::Testing,
Category::Utility,
];
pub fn allows_multiple(&self) -> bool {
matches!(self, Category::Testing | Category::Utility)
}
}
#[derive(Clone, Copy)]
pub struct Submodule {
pub dir: &'static str,
pub path: &'static str,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Realm {
Shared,
Server,
}
const MIXED_TABLE_IDIOM: &[&str] = &["vide"];
pub fn allows_mixed_tables<'a>(selected: impl IntoIterator<Item = &'a String>) -> bool {
selected.into_iter().any(|key| MIXED_TABLE_IDIOM.contains(&key.as_str()))
}
pub struct PackageSpec {
pub key: &'static str,
pub source: &'static str,
pub realm: Realm,
pub git_repo: &'static str,
pub module_name: &'static str,
pub submodule: Option<Submodule>,
pub requires: &'static [&'static str],
pub description: &'static str,
pub maintenance: Maintenance,
pub category: Category,
pub docs_url: &'static str,
pub primary_choice: bool,
}
pub fn companions_for(key: &str, has: impl Fn(&str) -> bool) -> Vec<&'static str> {
match key {
"react" => vec!["reactRoblox"],
"reflex" if has("react") => vec!["reactReflex"],
"charm" => {
let mut companions = vec!["charmSync"];
if has("react") {
companions.push("reactCharm");
} else if has("vide") {
companions.push("videCharm");
}
companions
}
"ripple" if has("react") => vec!["reactRipple"],
"ripple" if has("vide") => vec!["videRipple"],
_ => vec![],
}
}
pub const PACKAGES: &[PackageSpec] = &[
PackageSpec {
key: "react",
source: "jsdotlua/react@17.2.1",
realm: Realm::Shared,
git_repo: "https://github.com/jsdotlua/react-lua",
module_name: "React",
submodule: None,
requires: &[],
description: "Roact-style declarative UI library, a Luau port of React",
maintenance: Maintenance::Active,
category: Category::Ui,
docs_url: "https://jsdotlua.github.io/react-lua/",
primary_choice: true,
},
PackageSpec {
key: "reactRoblox",
source: "jsdotlua/react-roblox@17.2.1",
realm: Realm::Shared,
git_repo: "https://github.com/jsdotlua/react-lua",
module_name: "ReactRoblox",
submodule: None,
requires: &[],
description: "React's Roblox renderer - required alongside react to mount anything",
maintenance: Maintenance::Active,
category: Category::Ui,
docs_url: "https://jsdotlua.github.io/react-lua/",
primary_choice: false,
},
PackageSpec {
key: "vide",
source: "centau/vide@0.4.1",
realm: Realm::Shared,
git_repo: "https://github.com/centau/vide",
module_name: "Vide",
submodule: Some(Submodule { dir: "vide", path: "src" }),
requires: &[],
description: "Lightweight reactive UI + state library built for Luau",
maintenance: Maintenance::Active,
category: Category::Ui,
docs_url: "https://centau.github.io/vide/",
primary_choice: true,
},
PackageSpec {
key: "fusion",
source: "elttob/fusion@0.3.0",
realm: Realm::Shared,
git_repo: "https://github.com/dphfox/Fusion",
module_name: "Fusion",
submodule: Some(Submodule { dir: "fusion", path: "src" }),
requires: &[],
description: "Reactive UI library with state management built in",
maintenance: Maintenance::Active,
category: Category::Ui,
docs_url: "https://elttob.uk/Fusion/",
primary_choice: true,
},
PackageSpec {
key: "reflex",
source: "littensy/reflex@4.3.1",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/reflex",
module_name: "Reflex",
submodule: Some(Submodule { dir: "reflex", path: "src" }),
requires: &["promise"],
description: "Redux-inspired predictable state container",
maintenance: Maintenance::Active,
category: Category::StateManagement,
docs_url: "https://littensy.github.io/reflex/",
primary_choice: true,
},
PackageSpec {
key: "reactReflex",
source: "littensy/react-reflex@0.3.6",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/react-reflex",
module_name: "ReactReflex",
submodule: Some(Submodule { dir: "react-reflex", path: "src" }),
requires: &["react", "reflex"],
description: "React bindings for Reflex",
maintenance: Maintenance::Active,
category: Category::StateManagement,
docs_url: "https://littensy.github.io/reflex/",
primary_choice: false,
},
PackageSpec {
key: "charm",
source: "littensy/charm@0.11.0",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/charm",
module_name: "Charm",
submodule: Some(Submodule { dir: "charm", path: "packages/charm/src" }),
requires: &[],
description: "Atom-based state management, inspired by Jotai/Nanostores",
maintenance: Maintenance::Active,
category: Category::StateManagement,
docs_url: "https://github.com/littensy/charm",
primary_choice: true,
},
PackageSpec {
key: "charmSync",
source: "littensy/charm-sync@0.4.0",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/charm",
module_name: "CharmSync",
submodule: Some(Submodule { dir: "charm", path: "packages/charm-sync/src" }),
requires: &["charm"],
description: "Client/server atom synchronization for Charm",
maintenance: Maintenance::Active,
category: Category::StateManagement,
docs_url: "https://github.com/littensy/charm",
primary_choice: false,
},
PackageSpec {
key: "reactCharm",
source: "littensy/react-charm@0.4.0",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/charm",
module_name: "ReactCharm",
submodule: None,
requires: &[],
description: "React bindings for Charm",
maintenance: Maintenance::Active,
category: Category::StateManagement,
docs_url: "https://github.com/littensy/charm",
primary_choice: false,
},
PackageSpec {
key: "videCharm",
source: "littensy/vide-charm@0.4.0",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/charm",
module_name: "VideCharm",
submodule: Some(Submodule { dir: "charm", path: "packages/vide-charm/src" }),
requires: &["charm", "vide"],
description: "Bridge between Vide and Charm, for using Charm atoms in Vide UI",
maintenance: Maintenance::Active,
category: Category::StateManagement,
docs_url: "https://github.com/littensy/charm",
primary_choice: false,
},
PackageSpec {
key: "lyra",
source: "paradoxum-games/lyra@0.6.0",
realm: Realm::Shared,
git_repo: "https://github.com/paradoxum-games/lyra",
module_name: "Lyra",
submodule: Some(Submodule { dir: "lyra", path: "src" }),
requires: &["promise", "t"],
description: "Full game framework with a built-in player-data/profile layer",
maintenance: Maintenance::Active,
category: Category::DataProfile,
docs_url: "https://paradoxum-games.github.io/lyra/",
primary_choice: true,
},
PackageSpec {
key: "profilestore",
source: "lm-loleris/profilestore@1.0.3",
realm: Realm::Server,
git_repo: "https://github.com/MadStudioRoblox/ProfileStore",
module_name: "ProfileStore",
submodule: Some(Submodule { dir: "profilestore", path: "ProfileStore.luau" }),
requires: &[],
description: "DataStore session-locking wrapper - the successor to ProfileService, recommended for new projects",
maintenance: Maintenance::Active,
category: Category::DataProfile,
docs_url: "https://madstudioroblox.github.io/ProfileStore/",
primary_choice: true,
},
PackageSpec {
key: "testez",
source: "roblox/testez@0.4.1",
realm: Realm::Shared,
git_repo: "https://github.com/Roblox/testez",
module_name: "TestEZ",
submodule: Some(Submodule { dir: "testez", path: "src" }),
requires: &[],
description: "Roblox's own BDD-style unit testing framework - archived by Roblox in Sept 2024, no longer receiving updates upstream, but still the most common Wally-installable test framework in existing projects",
maintenance: Maintenance::Legacy,
category: Category::Testing,
docs_url: "https://roblox.github.io/testez/",
primary_choice: true,
},
PackageSpec {
key: "janitor",
source: "howmanysmall/janitor@1.18.3",
realm: Realm::Shared,
git_repo: "https://github.com/howmanysmall/Janitor",
module_name: "Janitor",
submodule: Some(Submodule { dir: "janitor", path: "src" }),
requires: &[],
description: "Cleanup/connection-management utility (a faster, typed Maid)",
maintenance: Maintenance::Active,
category: Category::Utility,
docs_url: "https://howmanysmall.github.io/Janitor/",
primary_choice: true,
},
PackageSpec {
key: "ripple",
source: "littensy/ripple@0.10.2",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/ripple",
module_name: "Ripple",
submodule: Some(Submodule { dir: "ripple", path: "packages/ripple/src" }),
requires: &[],
description: "Spring/tween-based animation library for Roblox UI, inspired by react-spring",
maintenance: Maintenance::Active,
category: Category::Utility,
docs_url: "https://github.com/littensy/ripple",
primary_choice: true,
},
PackageSpec {
key: "reactRipple",
source: "littensy/react-ripple@3.0.1",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/ripple",
module_name: "ReactRipple",
submodule: None,
requires: &[],
description: "React bindings for Ripple's animation primitives",
maintenance: Maintenance::Active,
category: Category::Utility,
docs_url: "https://github.com/littensy/ripple",
primary_choice: false,
},
PackageSpec {
key: "videRipple",
source: "littensy/vide-ripple@0.10.2",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/ripple",
module_name: "VideRipple",
submodule: Some(Submodule { dir: "ripple", path: "packages/vide-ripple/src" }),
requires: &["ripple", "vide"],
description: "Vide bindings for Ripple's animation primitives",
maintenance: Maintenance::Active,
category: Category::Utility,
docs_url: "https://github.com/littensy/ripple",
primary_choice: false,
},
PackageSpec {
key: "remo",
source: "littensy/remo@1.5.3",
realm: Realm::Shared,
git_repo: "https://github.com/littensy/remo",
module_name: "Remo",
submodule: Some(Submodule { dir: "remo", path: "src" }),
requires: &["promise"],
description: "Type-safe remote event/networking wrapper",
maintenance: Maintenance::Active,
category: Category::Utility,
docs_url: "https://github.com/littensy/remo",
primary_choice: true,
},
PackageSpec {
key: "promise",
source: "evaera/promise@4.0.0",
realm: Realm::Shared,
git_repo: "https://github.com/evaera/roblox-lua-promise",
module_name: "Promise",
submodule: Some(Submodule { dir: "promise", path: "lib" }),
requires: &[],
description: "Promise/A+-style async utility for Luau",
maintenance: Maintenance::CommunityStable,
category: Category::Utility,
docs_url: "https://eryn.io/roblox-lua-promise/",
primary_choice: true,
},
PackageSpec {
key: "greentea",
source: "corecii/greentea@0.4.11",
realm: Realm::Shared,
git_repo: "https://github.com/corecii/greentea",
module_name: "gt",
submodule: Some(Submodule { dir: "greentea", path: "src" }),
requires: &[],
description: "Runtime type-checking utility",
maintenance: Maintenance::CommunityStable,
category: Category::Utility,
docs_url: "https://github.com/corecii/greentea",
primary_choice: true,
},
PackageSpec {
key: "t",
source: "osyrisrblx/t@3.1.1",
realm: Realm::Shared,
git_repo: "https://github.com/osyrisrblx/t",
module_name: "t",
submodule: Some(Submodule { dir: "t", path: "lib" }),
requires: &[],
description: "Runtime type checker - validates values (e.g. RemoteEvent payloads) against type definitions",
maintenance: Maintenance::CommunityStable,
category: Category::Utility,
docs_url: "https://github.com/osyrisrblx/t",
primary_choice: true,
},
PackageSpec {
key: "sift",
source: "csqrl/sift@0.0.11",
realm: Realm::Shared,
git_repo: "https://github.com/csqrl/sift",
module_name: "Sift",
submodule: Some(Submodule { dir: "sift", path: "src" }),
requires: &[],
description: "Immutable data utility library for tables/arrays (Llama-style helpers) - no longer actively maintained upstream, but stable and widely used",
maintenance: Maintenance::CommunityStable,
category: Category::Utility,
docs_url: "https://cxmeel.github.io/sift",
primary_choice: true,
},
];
impl PackageSpec {
pub fn author(&self) -> &'static str {
self.source.split('/').next().unwrap_or(self.source)
}
pub fn version(&self) -> &'static str {
self.source.rsplit('@').next().unwrap_or("")
}
}
pub fn find(key: &str) -> Option<&'static PackageSpec> {
PACKAGES.iter().find(|p| p.key == key)
}
pub fn in_category(category: Category) -> impl Iterator<Item = &'static PackageSpec> {
PACKAGES.iter().filter(move |p| p.category == category)
}
pub fn has_server_realm<'a>(keys: impl IntoIterator<Item = &'a String>) -> bool {
keys.into_iter().filter_map(|k| find(k)).any(|p| p.realm == Realm::Server)
}
pub fn with_dependencies(selected: &BTreeSet<String>) -> BTreeSet<String> {
let mut resolved = selected.clone();
let mut queue: Vec<String> = selected.iter().cloned().collect();
while let Some(key) = queue.pop() {
let Some(spec) = find(&key) else { continue };
for dep in spec.requires {
if resolved.insert((*dep).to_string()) {
queue.push((*dep).to_string());
}
}
}
resolved
}
pub fn unvendorable_in_closure(selected: &BTreeSet<String>) -> Vec<(&'static str, Option<&'static str>)> {
let mut blocked = Vec::new();
for key in with_dependencies(selected) {
let Some(spec) = find(&key) else { continue };
if spec.submodule.is_some() {
continue;
}
let pulled_in_by = (!selected.contains(&key))
.then(|| {
PACKAGES
.iter()
.find(|p| selected.contains(p.key) && p.requires.contains(&spec.key))
.map(|p| p.key)
})
.flatten();
blocked.push((spec.key, pulled_in_by));
}
blocked
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_create_style_ui_library_waives_the_mixed_table_lint() {
let vide: BTreeSet<String> = ["vide".to_string()].into_iter().collect();
assert!(allows_mixed_tables(&vide));
for key in ["fusion", "react", "charm", "reflex"] {
let other: BTreeSet<String> = [key.to_string()].into_iter().collect();
assert!(!allows_mixed_tables(&other), "{key} should keep the lint");
}
assert!(!allows_mixed_tables(&BTreeSet::new()));
}
#[test]
fn profilestore_is_the_server_realm_package() {
let profilestore = find("profilestore").expect("profilestore is in the catalog");
assert!(profilestore.realm == Realm::Server, "ProfileStore is published server-realm");
for spec in PACKAGES.iter().filter(|p| p.key != "profilestore") {
assert!(
spec.realm == Realm::Shared,
"{} is marked server-realm; confirm with a real `wally install` before trusting it",
spec.key
);
}
}
fn owned(keys: &[&str]) -> BTreeSet<String> {
keys.iter().map(|k| (*k).to_string()).collect()
}
#[test]
fn every_required_key_names_a_real_package() {
for spec in PACKAGES {
for dep in spec.requires {
assert!(find(dep).is_some(), "{}'s requires names unknown `{dep}`", spec.key);
assert_ne!(*dep, spec.key, "{} requires itself", spec.key);
}
}
}
#[test]
fn dependencies_are_pulled_in_transitively() {
assert_eq!(with_dependencies(&owned(&["lyra"])), owned(&["lyra", "promise", "t"]));
assert_eq!(with_dependencies(&owned(&["charmSync"])), owned(&["charm", "charmSync"]));
assert_eq!(
with_dependencies(&owned(&["videRipple"])),
owned(&["ripple", "vide", "videRipple"])
);
assert_eq!(with_dependencies(&owned(&["charm"])), owned(&["charm"]));
assert_eq!(with_dependencies(&owned(&[])), owned(&[]));
assert_eq!(with_dependencies(&owned(&["nope"])), owned(&["nope"]));
}
#[test]
fn unvendorable_dependencies_are_reported_with_the_package_that_needs_them() {
let blocked = unvendorable_in_closure(&owned(&["reactReflex"]));
assert_eq!(blocked, vec![("react", Some("reactReflex"))], "{blocked:?}");
assert_eq!(unvendorable_in_closure(&owned(&["react"])), vec![("react", None)]);
assert!(unvendorable_in_closure(&owned(&["lyra", "charm"])).is_empty());
}
#[test]
fn vendorable_packages_either_resolve_or_are_caught_by_the_guard() {
for spec in PACKAGES.iter().filter(|p| p.submodule.is_some()) {
let selection = owned(&[spec.key]);
let blocked = unvendorable_in_closure(&selection);
if blocked.is_empty() {
for dep in spec.requires {
assert!(
with_dependencies(&selection).contains(*dep),
"{} requires {dep}, which the closure dropped",
spec.key
);
}
} else {
assert!(
blocked.iter().any(|(_, via)| *via == Some(spec.key)),
"{} is blocked but nothing explains why: {blocked:?}",
spec.key
);
}
}
}
#[test]
fn has_server_realm_tracks_the_selection() {
let owned = |keys: &[&str]| keys.iter().map(|k| (*k).to_string()).collect::<Vec<_>>();
assert!(has_server_realm(&owned(&["charm", "profilestore"])));
assert!(has_server_realm(&owned(&["profilestore"])));
assert!(!has_server_realm(&owned(&["charm", "lyra", "remo"])));
assert!(!has_server_realm(&owned(&[])));
assert!(!has_server_realm(&owned(&["not-a-package"])));
}
}