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 {
let create = create.trim();
if create.is_empty() {
return Err(ConfigError {
message: "luaux.toml: [factory] create cannot be empty".to_string(),
});
}
validate_create(create)?;
config.create = create.to_string();
}
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 validate_create(create: &str) -> Result<(), ConfigError> {
let reject = |reason: &str| {
Err(ConfigError {
message: format!(
"luaux.toml: [factory] create = \"{create}\" {reason}; it has to name a \
function, as create, vide.create, or scope:New"
),
})
};
if create.contains("..") {
return reject("has a `.` with no name beside it");
}
let probe = format!("local _ = ({create}(\"Frame\")({{}}))");
if full_moon::parse_fallible(&probe, full_moon::LuaVersion::luau())
.into_result()
.is_err()
{
return reject("is not something luaux can call");
}
Ok(())
}
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"));
assert!(parse_err("[factory]\ncreate = \" \"\n").contains("cannot be empty"));
}
#[test]
fn accepts_every_factory_that_lowers_to_a_call() {
for create in [
"create",
"vide.create",
"scope:New",
"a.b.c",
"_v1.x2",
"vide.create()",
"ui.factories[1]",
] {
let config = parse(&format!("[factory]\ncreate = \"{create}\"\n"));
assert_eq!(config.create, create);
let probe = format!("local _ = ({create}(\"Frame\")({{}}))");
assert!(
full_moon::parse_fallible(&probe, full_moon::LuaVersion::luau())
.into_result()
.is_ok(),
"{create}"
);
}
}
#[test]
fn rejects_a_factory_that_will_not_lower_to_luau() {
for (create, reason) in [
("vide..create", "with no name beside it"),
("vide.", "not something luaux can call"),
(".create", "not something luaux can call"),
("create(", "not something luaux can call"),
("vide create", "not something luaux can call"),
("1 + 1", "not something luaux can call"),
("scope:New:Now", "not something luaux can call"),
("scope:New.Now", "not something luaux can call"),
("scope:", "not something luaux can call"),
("end", "not something luaux can call"),
("a.end", "not something luaux can call"),
] {
let error = parse_err(&format!("[factory]\ncreate = \"{create}\"\n"));
assert!(error.contains("[factory] create"), "{create}: {error}");
assert!(error.contains(reason), "{create}: {error}");
}
}
#[test]
fn a_factory_that_lowers_but_names_nothing_is_left_to_the_scope_check() {
assert_eq!(
parse("[factory]\ncreate = \"(vide.create)\"\n").create,
"(vide.create)"
);
}
#[test]
fn a_factory_is_trimmed() {
assert_eq!(
parse("[factory]\ncreate = \" vide.create \"\n").create,
"vide.create"
);
}
#[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}");
}
}