use crate::roblox;
use serde::Deserialize;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigError {
pub message: String,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
elements: HashMap<String, String>,
#[serde(default)]
properties: HashMap<String, PropertyEntry>,
#[serde(default)]
lints: RawLints,
#[serde(default)]
factory: RawFactory,
#[serde(default)]
build: RawBuild,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawBuild {
#[serde(rename = "in")]
input: Option<String>,
#[serde(rename = "out")]
output: Option<String>,
include: Option<Vec<String>>,
exclude: Option<Vec<String>>,
clean: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFactory {
create: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawLints {
static_conditional_child: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Casing {
#[default]
Pascal,
Camel,
Snake,
Flat,
}
impl Casing {
fn parse(text: &str) -> Option<Self> {
match text {
"PascalCase" => Some(Self::Pascal),
"camelCase" => Some(Self::Camel),
"snake_case" => Some(Self::Snake),
"flatcase" => Some(Self::Flat),
_ => None,
}
}
pub fn apply(&self, name: &str) -> String {
if *self == Casing::Pascal {
return name.to_string();
}
if *self == Casing::Flat {
return name.to_lowercase();
}
let mut words: Vec<String> = Vec::new();
let chars: Vec<char> = name.chars().collect();
let mut start = 0;
for index in 1..chars.len() {
let previous = chars[index - 1];
let current = chars[index];
let next = chars.get(index + 1).copied();
let boundary = (previous.is_lowercase() || previous.is_numeric())
&& current.is_uppercase()
|| previous.is_uppercase()
&& current.is_uppercase()
&& next.is_some_and(char::is_lowercase);
if boundary {
words.push(chars[start..index].iter().collect());
start = index;
}
}
words.push(chars[start..].iter().collect());
match self {
Casing::Snake => words
.iter()
.map(|word| word.to_lowercase())
.collect::<Vec<_>>()
.join("_"),
Casing::Camel => words
.iter()
.enumerate()
.map(|(index, word)| {
if index == 0 {
word.to_lowercase()
} else {
let mut chars = word.chars();
match chars.next() {
Some(first) => {
first.to_uppercase().collect::<String>()
+ &chars.as_str().to_lowercase()
}
None => String::new(),
}
}
})
.collect(),
Casing::Pascal | Casing::Flat => unreachable!("handled above"),
}
}
}
pub fn preferred<'a>(candidates: &mut Vec<&'a str>) -> Option<&'a str> {
if candidates.len() > 1 && candidates.iter().any(|name| !roblox::is_deprecated(name)) {
candidates.retain(|name| !roblox::is_deprecated(name));
}
candidates.sort_unstable();
candidates.first().copied()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LintLevel {
Off,
#[default]
Warn,
Error,
}
impl LintLevel {
fn parse(text: &str) -> Option<Self> {
match text {
"off" => Some(Self::Off),
"warn" => Some(Self::Warn),
"error" => Some(Self::Error),
_ => None,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PropertyEntry {
Alias(String),
PerClass(HashMap<String, String>),
}
#[derive(Debug, Clone)]
pub struct Config {
element_alias: HashMap<String, String>,
element_renamed: HashMap<String, String>,
global_properties: HashMap<String, String>,
class_properties: HashMap<String, HashMap<String, String>>,
element_casing: Casing,
property_casing: Casing,
pub static_conditional_child: LintLevel,
pub build: Build,
pub create: String,
}
pub const DEFAULT_CREATE: &str = "create";
#[derive(Debug, Clone)]
pub struct Build {
pub input: Option<PathBuf>,
pub output: Option<PathBuf>,
pub include: Vec<String>,
pub exclude: Vec<String>,
pub clean: bool,
}
impl Default for Build {
fn default() -> Self {
Self {
input: None,
output: None,
include: vec!["**".to_string()],
exclude: Vec::new(),
clean: false,
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
element_alias: HashMap::new(),
element_renamed: HashMap::new(),
global_properties: HashMap::new(),
class_properties: HashMap::new(),
element_casing: Casing::default(),
property_casing: Casing::default(),
static_conditional_child: LintLevel::default(),
build: Build::default(),
create: DEFAULT_CREATE.to_string(),
}
}
}
impl Config {
pub fn element_casing(&self) -> Casing {
self.element_casing
}
pub fn property_casing(&self) -> Casing {
self.property_casing
}
pub fn with_create(create: impl Into<String>) -> Self {
Self {
create: create.into(),
..Self::default()
}
}
pub fn load(directory: &Path) -> Result<Self, ConfigError> {
Self::load_reporting(directory).map(|(config, _)| config)
}
pub fn load_reporting(directory: &Path) -> Result<(Self, Vec<String>), ConfigError> {
let path = directory.join("luaux.toml");
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok((Self::default(), Vec::new()))
}
Err(error) => {
return Err(ConfigError {
message: format!("{}: {error}", path.display()),
})
}
};
Self::parse_reporting(&text)
}
pub fn parse(text: &str) -> Result<Self, ConfigError> {
Self::parse_reporting(text).map(|(config, _)| config)
}
pub fn parse_reporting(text: &str) -> Result<(Self, Vec<String>), ConfigError> {
let raw: RawConfig = toml::from_str(text).map_err(|error| ConfigError {
message: format!("luaux.toml: {}", error.message()),
})?;
let mut config = Config::default();
if let Some(level) = &raw.lints.static_conditional_child {
config.static_conditional_child =
LintLevel::parse(level).ok_or_else(|| ConfigError {
message: format!(
"luaux.toml: [lints] static_conditional_child = \"{level}\" is not one of \
off, warn, error"
),
})?;
}
let warnings = Vec::new();
config.build.input = raw.build.input.map(PathBuf::from);
config.build.output = raw.build.output.map(PathBuf::from);
if let Some(include) = raw.build.include {
config.build.include = include;
}
if let Some(exclude) = raw.build.exclude {
config.build.exclude = exclude;
}
config.build.clean = raw.build.clean.unwrap_or(false);
if let Some(create) = raw.factory.create {
if create.trim().is_empty() {
return Err(ConfigError {
message: "luaux.toml: [factory] create cannot be empty".to_string(),
});
}
config.create = create;
}
if let Some(value) = raw.elements.get("all") {
config.element_casing = Casing::parse(value).ok_or_else(|| ConfigError {
message: format!(
"luaux.toml: [elements] all = \"{value}\" is not one of PascalCase, \
camelCase, snake_case, flatcase"
),
})?;
}
for (class, alias) in raw.elements.iter().filter(|(key, _)| *key != "all") {
if !roblox::is_class(class) {
return Err(ConfigError {
message: format!(
"luaux.toml: [elements] {class} is not a creatable Roblox class{}",
suggest_class(class)
),
});
}
if let Some(existing) = config.element_alias.get(alias) {
return Err(ConfigError {
message: format!(
"luaux.toml: [elements] {existing} and {class} both claim the alias \
\"{alias}\""
),
});
}
config.element_alias.insert(alias.clone(), class.clone());
config.element_renamed.insert(class.clone(), alias.clone());
}
if let Some(PropertyEntry::Alias(value)) = raw.properties.get("all") {
config.property_casing = Casing::parse(value).ok_or_else(|| ConfigError {
message: format!(
"luaux.toml: [properties] all = \"{value}\" is not one of PascalCase, \
camelCase, snake_case, flatcase"
),
})?;
}
for (key, entry) in raw.properties.iter().filter(|(key, _)| *key != "all") {
match entry {
PropertyEntry::Alias(alias) => {
if !roblox::is_member_name(key) {
return Err(ConfigError {
message: format!(
"luaux.toml: [properties] {key} is not a property or event of any \
Roblox class{}",
suggest_member_anywhere(key)
),
});
}
config.global_properties.insert(key.clone(), alias.clone());
}
PropertyEntry::PerClass(entries) => {
if !roblox::is_class(key) {
return Err(ConfigError {
message: format!(
"luaux.toml: [properties.{key}] is not a creatable Roblox class{}",
suggest_class(key)
),
});
}
for property in entries.keys() {
if !roblox::has_property(key, property) && !roblox::is_event(key, property)
{
return Err(ConfigError {
message: format!(
"luaux.toml: [properties.{key}] {key} has no property or event \
named {property}{}",
suggest_member(key, property)
),
});
}
}
config.class_properties.insert(key.clone(), entries.clone());
}
}
}
Ok((config, warnings))
}
pub fn resolve_element(&self, written: &str) -> Result<Option<&str>, String> {
if let Some(class) = self.element_alias.get(written) {
return Ok(Some(class));
}
if let Some(alias) = self.element_renamed.get(written) {
if alias != written {
return Err(format!(
"<{written}> was renamed by luaux.toml; use <{alias}>"
));
}
}
if self.element_casing != Casing::Pascal {
let mut matches: Vec<&str> = roblox::creatable_classes()
.filter(|class| !self.element_renamed.contains_key(*class))
.filter(|class| self.element_casing.apply(class) == written)
.collect();
if let Some(class) = preferred(&mut matches) {
return Ok(Some(class));
}
if roblox::is_class(written) {
return Err(format!(
"<{written}> was renamed by [elements] all; use <{}>",
self.element_casing.apply(written)
));
}
}
Ok(None)
}
pub fn element_name<'a>(&'a self, class: &'a str) -> &'a str {
self.element_renamed
.get(class)
.map_or(class, String::as_str)
}
pub fn property_name<'a>(&'a self, class: &str, canonical: &'a str) -> &'a str {
if let Some(alias) = self
.class_properties
.get(class)
.and_then(|entries| entries.get(canonical))
{
return alias;
}
self.global_properties
.get(canonical)
.map_or(canonical, String::as_str)
}
pub fn resolve_property(&self, class: &str, written: &str) -> Result<String, String> {
let effective: HashMap<&str, &str> = self
.global_properties
.iter()
.map(|(canonical, alias)| (canonical.as_str(), alias.as_str()))
.chain(
self.class_properties
.get(class)
.into_iter()
.flatten()
.map(|(canonical, alias)| (canonical.as_str(), alias.as_str())),
)
.collect();
for (canonical, alias) in &effective {
if *alias == written {
return Ok((*canonical).to_string());
}
}
if let Some(alias) = effective.get(written) {
if *alias != written {
return Err(format!("{written} was renamed by luaux.toml; use {alias}"));
}
}
if self.property_casing != Casing::Pascal {
let mut matches: Vec<&str> = roblox::properties(class)
.chain(roblox::events(class))
.filter(|member| !effective.contains_key(*member))
.filter(|member| self.property_casing.apply(member) == written)
.collect();
if let Some(member) = preferred(&mut matches) {
return Ok(member.to_string());
}
if roblox::has_property(class, written) || roblox::is_event(class, written) {
return Err(format!(
"{written} was renamed by [properties] all; use {}",
self.property_casing.apply(written)
));
}
}
Ok(written.to_string())
}
}
fn suggest_class(name: &str) -> String {
match roblox::closest_class(name) {
Some(class) => format!("; did you mean {class}?"),
None => String::new(),
}
}
fn suggest_member(class: &str, name: &str) -> String {
match roblox::closest_members(class, name).as_slice() {
[] => String::new(),
[one] => format!("; did you mean {one}?"),
[rest @ .., last] => format!("; did you mean {} or {last}?", rest.join(", ")),
}
}
fn suggest_member_anywhere(name: &str) -> String {
match roblox::closest_member_anywhere(name) {
Some(member) => format!("; did you mean {member}?"),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> Config {
Config::parse(text).expect("parse")
}
fn parse_err(text: &str) -> String {
Config::parse(text).expect_err("should fail").message
}
#[test]
fn lints_default_to_warn_and_are_configurable() {
assert_eq!(parse("").static_conditional_child, LintLevel::Warn);
assert_eq!(
parse("[lints]\nstatic_conditional_child = \"off\"\n").static_conditional_child,
LintLevel::Off
);
assert_eq!(
parse("[lints]\nstatic_conditional_child = \"error\"\n").static_conditional_child,
LintLevel::Error
);
}
#[test]
fn rejects_an_unknown_lint_level() {
let error = parse_err("[lints]\nstatic_conditional_child = \"loud\"\n");
assert!(error.contains("not one of off, warn, error"), "{error}");
}
#[test]
fn the_element_factory_defaults_to_bare_create() {
assert_eq!(parse("").create, DEFAULT_CREATE);
assert_eq!(
parse("[factory]\ncreate = \"vide.create\"\n").create,
"vide.create"
);
}
#[test]
fn rejects_an_empty_factory() {
assert!(parse_err("[factory]\ncreate = \"\"\n").contains("cannot be empty"));
}
#[test]
fn reads_build_paths_and_selection() {
let config = parse(
"[build]\nin = \"src\"\nout = \"build\"\ninclude = [\"**\"]\nexclude = [\"**/*.spec.luaux\"]\nclean = true\n",
);
assert_eq!(config.build.input.unwrap().to_str(), Some("src"));
assert_eq!(config.build.output.unwrap().to_str(), Some("build"));
assert_eq!(config.build.exclude, ["**/*.spec.luaux"]);
assert!(config.build.clean);
}
#[test]
fn build_defaults_are_inert() {
let config = parse("");
assert!(config.build.input.is_none());
assert!(config.build.output.is_none());
assert!(!config.build.clean);
assert_eq!(config.build.include, ["**"]);
}
#[test]
fn casing_splits_on_canonical_word_boundaries() {
for (name, snake, camel, flat) in [
("TextLabel", "text_label", "textLabel", "textlabel"),
("UICorner", "ui_corner", "uiCorner", "uicorner"),
(
"UIAspectRatioConstraint",
"ui_aspect_ratio_constraint",
"uiAspectRatioConstraint",
"uiaspectratioconstraint",
),
(
"BackgroundColor3",
"background_color3",
"backgroundColor3",
"backgroundcolor3",
),
("Frame", "frame", "frame", "frame"),
] {
assert_eq!(Casing::Snake.apply(name), snake, "{name}");
assert_eq!(Casing::Camel.apply(name), camel, "{name}");
assert_eq!(Casing::Flat.apply(name), flat, "{name}");
assert_eq!(Casing::Pascal.apply(name), name, "{name}");
}
}
#[test]
fn casing_is_injective_over_every_class() {
for casing in [Casing::Snake, Casing::Camel, Casing::Flat] {
let mut seen: HashMap<String, &str> = HashMap::new();
for class in roblox::creatable_classes() {
if let Some(other) = seen.insert(casing.apply(class), class) {
panic!("{casing:?}: {other} and {class} collide");
}
}
}
}
#[test]
fn an_explicit_entry_beats_the_blanket_scheme() {
let config = parse("[elements]\nall = \"camelCase\"\nTextLabel = \"text\"\n");
assert_eq!(config.resolve_element("text").unwrap(), Some("TextLabel"));
assert!(config.resolve_element("textLabel").unwrap().is_none());
assert_eq!(config.resolve_element("frame").unwrap(), Some("Frame"));
assert_eq!(
config.resolve_element("uiCorner").unwrap(),
Some("UICorner")
);
}
#[test]
fn a_blanket_scheme_retires_the_canonical_spelling() {
let config = parse("[elements]\nall = \"snake_case\"\n");
let error = config.resolve_element("Frame").expect_err("retired");
assert!(error.contains("use <frame>"), "{error}");
}
#[test]
fn a_collision_prefers_the_name_roblox_has_not_deprecated() {
let config = parse("[properties]\nall = \"snake_case\"\n");
assert_eq!(
config.resolve_property("Frame", "child_added").unwrap(),
"ChildAdded"
);
let camel = parse("[properties]\nall = \"camelCase\"\n");
assert_eq!(
camel.resolve_property("Part", "brickColor").unwrap(),
"BrickColor"
);
}
#[test]
fn a_collision_with_no_undeprecated_name_is_still_deterministic() {
let config = parse("[properties]\nall = \"camelCase\"\n");
let resolved = config.resolve_property("Part", "formFactor");
assert_eq!(resolved.unwrap(), "FormFactor");
}
#[test]
fn properties_follow_their_own_scheme_and_overrides() {
let config = parse("[properties]\nall = \"snake_case\"\nBackgroundColor3 = \"bg\"\n");
assert_eq!(
config
.resolve_property("Frame", "background_transparency")
.unwrap(),
"BackgroundTransparency"
);
assert_eq!(
config.resolve_property("Frame", "bg").unwrap(),
"BackgroundColor3"
);
assert_eq!(
config
.resolve_property("Frame", "background_color3")
.unwrap(),
"background_color3"
);
}
#[test]
fn rejects_an_unknown_casing() {
assert!(parse_err("[elements]\nall = \"kebab-case\"\n").contains("PascalCase"));
assert!(parse_err("[properties]\nall = \"KEBAB\"\n").contains("snake_case"));
}
#[test]
fn a_genuinely_unknown_section_still_errors() {
assert!(parse_err("[buld]\nclean = true\n").contains("unknown field"));
}
#[test]
fn an_absent_config_is_not_an_error() {
let config = Config::load(Path::new("/nonexistent-directory-for-luaux"));
assert!(config.is_ok());
}
#[test]
fn resolves_element_aliases() {
let config = parse("[elements]\nTextLabel = \"text\"\n");
assert_eq!(config.resolve_element("text"), Ok(Some("TextLabel")));
assert_eq!(config.resolve_element("Frame"), Ok(None));
}
#[test]
fn an_override_retires_the_original_name() {
let config = parse("[elements]\nTextLabel = \"text\"\n");
let error = config.resolve_element("TextLabel").expect_err("retired");
assert!(error.contains("use <text>"), "{error}");
}
#[test]
fn rejects_duplicate_element_aliases() {
let error = parse_err("[elements]\nTextLabel = \"text\"\nTextButton = \"text\"\n");
assert!(error.contains("both claim the alias"), "{error}");
}
#[test]
fn rejects_unknown_element_keys() {
let error = parse_err("[elements]\nFrmae = \"frame\"\n");
assert!(error.contains("not a creatable Roblox class"), "{error}");
assert!(error.contains("did you mean Frame?"), "{error}");
}
#[test]
fn resolves_global_property_aliases() {
let config = parse("[properties]\nTextColor3 = \"textColor\"\n");
assert_eq!(
config.resolve_property("TextLabel", "textColor"),
Ok("TextColor3".into())
);
assert!(config.resolve_property("TextLabel", "TextColor3").is_err());
}
#[test]
fn per_class_entries_beat_the_global_table() {
let config = parse(
"[properties]\nBackgroundColor3 = \"bg\"\n\n[properties.Frame]\nBackgroundColor3 = \"bgColor\"\n",
);
assert_eq!(
config.resolve_property("Frame", "bgColor"),
Ok("BackgroundColor3".into())
);
assert_eq!(config.resolve_property("Frame", "bg"), Ok("bg".into()));
assert_eq!(
config.resolve_property("TextLabel", "bg"),
Ok("BackgroundColor3".into())
);
}
#[test]
fn a_class_can_opt_out_of_a_global_rename() {
let config = parse(
"[properties]\nTextColor3 = \"textColor\"\n\n[properties.TextLabel]\nTextColor3 = \"TextColor3\"\n",
);
assert_eq!(
config.resolve_property("TextLabel", "TextColor3"),
Ok("TextColor3".into())
);
assert_eq!(
config.resolve_property("TextButton", "textColor"),
Ok("TextColor3".into())
);
}
#[test]
fn the_offered_spelling_is_the_one_that_resolves() {
let config = parse(
"[elements]\nTextLabel = \"text\"\n\n[properties]\nTextColor3 = \"textColor\"\n\n\
[properties.Frame]\nBackgroundColor3 = \"bgColor\"\n",
);
assert_eq!(config.element_name("TextLabel"), "text");
assert_eq!(config.element_name("Frame"), "Frame");
assert_eq!(
config.resolve_element(config.element_name("TextLabel")),
Ok(Some("TextLabel"))
);
assert_eq!(config.property_name("TextLabel", "TextColor3"), "textColor");
assert_eq!(config.property_name("Frame", "BackgroundColor3"), "bgColor");
assert_eq!(config.property_name("Frame", "Name"), "Name");
for (class, canonical) in [("TextLabel", "TextColor3"), ("Frame", "BackgroundColor3")] {
assert_eq!(
config.resolve_property(class, config.property_name(class, canonical)),
Ok(canonical.to_string()),
"{class}.{canonical}"
);
}
}
#[test]
fn an_identity_override_is_offered_as_the_original_name() {
let config = parse(
"[properties]\nTextColor3 = \"textColor\"\n\n[properties.TextLabel]\nTextColor3 = \"TextColor3\"\n",
);
assert_eq!(
config.property_name("TextLabel", "TextColor3"),
"TextColor3"
);
assert_eq!(
config.property_name("TextButton", "TextColor3"),
"textColor"
);
}
#[test]
fn rejects_unknown_property_keys() {
let error = parse_err("[properties]\nTextColour3 = \"textColor\"\n");
assert!(error.contains("not a property or event of any"), "{error}");
assert!(error.contains("TextColor3"), "{error}");
}
#[test]
fn rejects_per_class_keys_the_class_does_not_have() {
let error = parse_err("[properties.Frame]\nText = \"label\"\n");
assert!(
error.contains("has no property or event named Text"),
"{error}"
);
}
#[test]
fn rejects_unknown_per_class_tables() {
let error = parse_err("[properties.Frmae]\nName = \"id\"\n");
assert!(error.contains("not a creatable Roblox class"), "{error}");
}
}