use serde_json::{json, Value};
pub struct ChoiceOption {
pub value: &'static str,
pub explanation: &'static str,
}
pub enum SettingKind {
Bool { default: bool },
Integer { default: i64 },
Choice { default: &'static str, options: &'static [ChoiceOption] },
}
pub struct SettingSpec {
pub key: &'static str,
pub description: &'static str,
pub section: Option<&'static str>,
pub kind: SettingKind,
}
pub enum ConfigTarget {
ProjectToml { filename: &'static str },
VsCodeSettings,
}
impl SettingSpec {
pub fn display_key(&self) -> String {
match self.section {
Some(section) => format!("{section}.{}", self.key),
None => self.key.to_string(),
}
}
}
pub struct ConfigurableTool {
pub key: &'static str,
pub display_name: &'static str,
pub summary: &'static str,
pub target: ConfigTarget,
pub docs_url: &'static str,
pub settings: &'static [SettingSpec],
}
const LINT_LEVELS: &[ChoiceOption] = &[
ChoiceOption { value: "deny", explanation: "Report as an error and fail the lint run" },
ChoiceOption { value: "warn", explanation: "Report as a warning, but don't fail" },
ChoiceOption { value: "allow", explanation: "Don't report this at all" },
];
pub const CONFIGURABLE_TOOLS: &[ConfigurableTool] = &[
ConfigurableTool {
key: "stylua",
display_name: "StyLua",
summary: "Code formatter. These settings decide how your Luau is reshaped on format/save.",
target: ConfigTarget::ProjectToml { filename: "stylua.toml" },
docs_url: "https://github.com/JohnnyMorganz/StyLua#options",
settings: &[
SettingSpec {
key: "syntax",
description: "Which Lua/Luau dialect to parse as. Roblox projects want Luau - it enables type-annotation syntax the other dialects would reject.",
section: None,
kind: SettingKind::Choice {
default: "Luau",
options: &[
ChoiceOption { value: "Luau", explanation: "Roblox's Luau, including type annotations (what you want here)" },
ChoiceOption { value: "All", explanation: "Accept any supported dialect - StyLua's own default" },
ChoiceOption { value: "Lua51", explanation: "Plain Lua 5.1" },
ChoiceOption { value: "Lua54", explanation: "Plain Lua 5.4" },
],
},
},
SettingSpec {
key: "column_width",
description: "Line length StyLua aims for before wrapping. A guide, not a hard cap - lines can still run over when there's no good break.",
section: None,
kind: SettingKind::Integer { default: 120 },
},
SettingSpec {
key: "indent_type",
description: "Indent with real tab characters or with spaces. Tabs are StyLua's default and let each reader choose their own visual width.",
section: None,
kind: SettingKind::Choice {
default: "Tabs",
options: &[
ChoiceOption { value: "Tabs", explanation: "One tab character per level" },
ChoiceOption { value: "Spaces", explanation: "indent_width spaces per level" },
],
},
},
SettingSpec {
key: "indent_width",
description: "How many columns one indent level is. With indent_type = Tabs this only affects how StyLua estimates line width.",
section: None,
kind: SettingKind::Integer { default: 4 },
},
SettingSpec {
key: "quote_style",
description: "Which quote character string literals get. The AutoPrefer options switch to the other quote when it means fewer backslash escapes.",
section: None,
kind: SettingKind::Choice {
default: "AutoPreferDouble",
options: &[
ChoiceOption { value: "AutoPreferDouble", explanation: "Double quotes, unless single quotes need fewer escapes" },
ChoiceOption { value: "AutoPreferSingle", explanation: "Single quotes, unless double quotes need fewer escapes" },
ChoiceOption { value: "ForceDouble", explanation: "Always double quotes, escapes regardless" },
ChoiceOption { value: "ForceSingle", explanation: "Always single quotes, escapes regardless" },
],
},
},
SettingSpec {
key: "call_parentheses",
description: "Whether to keep parentheses on calls taking a single string or table, like require\"foo\" or f{...}.",
section: None,
kind: SettingKind::Choice {
default: "Always",
options: &[
ChoiceOption { value: "Always", explanation: "Always parenthesise - most explicit, easiest to read" },
ChoiceOption { value: "NoSingleString", explanation: "Drop them for a single string argument" },
ChoiceOption { value: "NoSingleTable", explanation: "Drop them for a single table argument" },
ChoiceOption { value: "None", explanation: "Drop them for both cases" },
ChoiceOption { value: "Input", explanation: "Leave exactly as written - no consistency enforced" },
],
},
},
SettingSpec {
key: "collapse_simple_statement",
description: "Whether a short single-statement block may be pulled onto one line, e.g. if x then return end.",
section: None,
kind: SettingKind::Choice {
default: "Never",
options: &[
ChoiceOption { value: "Never", explanation: "Always keep blocks expanded across lines" },
ChoiceOption { value: "FunctionOnly", explanation: "Collapse single-statement function bodies only" },
ChoiceOption { value: "ConditionalOnly", explanation: "Collapse single-statement if bodies only" },
ChoiceOption { value: "Always", explanation: "Collapse both" },
],
},
},
SettingSpec {
key: "line_endings",
description: "Line ending written to disk. Unix (LF) is the safe default even on Windows - it avoids whole-file diffs when teammates are on other platforms.",
section: None,
kind: SettingKind::Choice {
default: "Unix",
options: &[
ChoiceOption { value: "Unix", explanation: "LF" },
ChoiceOption { value: "Windows", explanation: "CRLF" },
],
},
},
SettingSpec {
key: "enabled",
description: "Sort consecutive require assignments alphabetically. Keeps import blocks tidy and cuts down merge conflicts at the top of files.",
section: Some("sort_requires"),
kind: SettingKind::Bool { default: true },
},
],
},
ConfigurableTool {
key: "selene",
display_name: "Selene",
summary: "Linter. `std` tells it which globals exist; the rest turn individual lints up or down.",
target: ConfigTarget::ProjectToml { filename: "selene.toml" },
docs_url: "https://kampfkarren.github.io/selene/usage/configuration.html",
settings: &[
SettingSpec {
key: "std",
description: "Which standard library to check against. Get this wrong and Selene flags every Roblox global as undefined.",
section: None,
kind: SettingKind::Choice {
default: "roblox",
options: &[
ChoiceOption { value: "roblox", explanation: "Roblox globals (game, workspace, script...)" },
ChoiceOption { value: "roblox+testez", explanation: "Roblox globals plus TestEZ's describe/it/expect" },
ChoiceOption { value: "luau", explanation: "Plain Luau, no Roblox globals" },
ChoiceOption { value: "lua51", explanation: "Plain Lua 5.1" },
],
},
},
SettingSpec {
key: "undefined_variable",
description: "Using a name that was never defined. Usually a typo or a missing require - worth keeping strict.",
section: Some("rules"),
kind: SettingKind::Choice { default: "deny", options: LINT_LEVELS },
},
SettingSpec {
key: "unused_variable",
description: "A local that's assigned but never read. Often leftover code; prefix with _ to intentionally ignore one.",
section: Some("rules"),
kind: SettingKind::Choice { default: "warn", options: LINT_LEVELS },
},
SettingSpec {
key: "shadowing",
description: "A local re-using a name already in scope. Legal, but a common source of confusing bugs.",
section: Some("rules"),
kind: SettingKind::Choice { default: "warn", options: LINT_LEVELS },
},
SettingSpec {
key: "global_usage",
description: "Reading or writing _G. Shared mutable global state is hard to trace in a multi-script game.",
section: Some("rules"),
kind: SettingKind::Choice { default: "warn", options: LINT_LEVELS },
},
SettingSpec {
key: "incorrect_standard_library_use",
description: "Calling a standard-library function with the wrong argument count or types.",
section: Some("rules"),
kind: SettingKind::Choice { default: "deny", options: LINT_LEVELS },
},
SettingSpec {
key: "mixed_table",
description: "A table with both array entries and key/value entries. Legal, but #t and ipairs behave surprisingly on them.",
section: Some("rules"),
kind: SettingKind::Choice { default: "warn", options: LINT_LEVELS },
},
SettingSpec {
key: "multiple_statements",
description: "More than one statement on a single line. Mostly a readability preference.",
section: Some("rules"),
kind: SettingKind::Choice { default: "allow", options: LINT_LEVELS },
},
SettingSpec {
key: "roblox_incorrect_roact_usage",
description: "Roact/React mistakes Selene can spot statically, like an invalid instance name in createElement.",
section: Some("rules"),
kind: SettingKind::Choice { default: "deny", options: LINT_LEVELS },
},
],
},
ConfigurableTool {
key: "luau-lsp",
display_name: "Luau Language Server (VS Code)",
summary: "Autocomplete, type checking and inlay hints. Sourcemap settings are what make requires resolve.",
target: ConfigTarget::VsCodeSettings,
docs_url: "https://github.com/JohnnyMorganz/luau-lsp",
settings: &[
SettingSpec {
key: "luau-lsp.sourcemap.enabled",
description: "Use a Rojo sourcemap to understand your DataModel. Without this, requires through ReplicatedStorage resolve to nothing and you get no autocomplete on your own modules.",
section: None,
kind: SettingKind::Bool { default: true },
},
SettingSpec {
key: "luau-lsp.sourcemap.autogenerate",
description: "Let the extension run Rojo itself to keep sourcemap.json fresh. Off by default here because `rproj watch` already runs `rojo sourcemap --watch` - turn it on if you'd rather not run a watcher, but don't run both.",
section: None,
kind: SettingKind::Bool { default: false },
},
SettingSpec {
key: "luau-lsp.sourcemap.rojoProjectFile",
description: "Which project file to build the sourcemap from. rproj scaffolds default.project.json.",
section: None,
kind: SettingKind::Choice {
default: "default.project.json",
options: &[ChoiceOption {
value: "default.project.json",
explanation: "The project file rproj generates",
}],
},
},
SettingSpec {
key: "luau-lsp.types.roblox",
description: "Load Roblox's API type definitions, so Instance, Vector3 and the rest are known types.",
section: None,
kind: SettingKind::Bool { default: true },
},
SettingSpec {
key: "luau-lsp.platform.type",
description: "Which platform's globals and types to assume.",
section: None,
kind: SettingKind::Choice {
default: "roblox",
options: &[
ChoiceOption { value: "roblox", explanation: "Roblox - what you want for a Rojo project" },
ChoiceOption { value: "standard", explanation: "Plain Luau with no Roblox API" },
],
},
},
SettingSpec {
key: "luau-lsp.completion.autocompleteEnd",
description: "Automatically insert the matching `end` when you open a block.",
section: None,
kind: SettingKind::Bool { default: true },
},
SettingSpec {
key: "luau-lsp.completion.imports.enabled",
description: "Offer to add the require line for you when you complete a module name that isn't imported yet.",
section: None,
kind: SettingKind::Bool { default: true },
},
SettingSpec {
key: "luau-lsp.inlayHints.parameterNames",
description: "Show parameter names inline at call sites, so f(true, 3) reads as f(enabled: true, count: 3).",
section: None,
kind: SettingKind::Choice {
default: "literals",
options: &[
ChoiceOption { value: "none", explanation: "No parameter name hints" },
ChoiceOption { value: "literals", explanation: "Only for literal arguments, where it helps most" },
ChoiceOption { value: "all", explanation: "For every argument - thorough but noisy" },
],
},
},
SettingSpec {
key: "luau-lsp.inlayHints.variableTypes",
description: "Show the inferred type next to variables you didn't annotate.",
section: None,
kind: SettingKind::Bool { default: false },
},
SettingSpec {
key: "luau-lsp.inlayHints.functionReturnTypes",
description: "Show the inferred return type on functions you didn't annotate.",
section: None,
kind: SettingKind::Bool { default: false },
},
SettingSpec {
key: "luau-lsp.diagnostics.strictDatamodelTypes",
description: "Type instances from the sourcemap exactly rather than loosely. Catches real mistakes, but flags code that looks up instances dynamically.",
section: None,
kind: SettingKind::Bool { default: false },
},
SettingSpec {
key: "luau-lsp.diagnostics.workspace",
description: "Report problems across the whole project, not just files you have open. Slower on large projects.",
section: None,
kind: SettingKind::Bool { default: false },
},
SettingSpec {
key: "luau-lsp.plugin.enabled",
description: "Accept live DataModel data from the companion Studio plugin, so instances that exist only in Studio still autocomplete.",
section: None,
kind: SettingKind::Bool { default: true },
},
],
},
ConfigurableTool {
key: "stylua-vscode",
display_name: "StyLua (VS Code editor behaviour)",
summary: "How the editor applies StyLua - the formatting rules themselves live in stylua.toml (`rproj configure stylua`).",
target: ConfigTarget::VsCodeSettings,
docs_url: "https://marketplace.visualstudio.com/items?itemName=JohnnyMorganz.stylua",
settings: &[
SettingSpec {
key: "editor.formatOnSave",
description: "Format every time you save. The usual way teams keep formatting out of code review entirely.",
section: None,
kind: SettingKind::Bool { default: true },
},
SettingSpec {
key: "stylua.searchParentDirectories",
description: "Look in parent folders for stylua.toml when the current folder has none.",
section: None,
kind: SettingKind::Bool { default: true },
},
],
},
];
pub fn find(key: &str) -> Option<&'static ConfigurableTool> {
CONFIGURABLE_TOOLS.iter().find(|t| t.key == key)
}
impl SettingKind {
pub fn default_value(&self) -> Value {
match self {
SettingKind::Bool { default } => json!(default),
SettingKind::Integer { default } => json!(default),
SettingKind::Choice { default, .. } => json!(default),
}
}
}
pub fn render_toml(answers: &[(&SettingSpec, Value)]) -> String {
let mut out = String::new();
for (setting, value) in answers.iter().filter(|(s, _)| s.section.is_none()) {
out.push_str(&format!("{} = {}\n", setting.key, toml_value(value)));
}
let mut sections: Vec<&str> = answers.iter().filter_map(|(s, _)| s.section).collect();
sections.dedup();
for section in sections {
out.push_str(&format!("\n[{section}]\n"));
for (setting, value) in answers.iter().filter(|(s, _)| s.section == Some(section)) {
out.push_str(&format!("{} = {}\n", setting.key, toml_value(value)));
}
}
out
}
fn toml_value(value: &Value) -> String {
match value {
Value::String(s) => format!("\"{}\"", escape_toml_string(s)),
other => other.to_string(),
}
}
fn escape_toml_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
c => out.push(c),
}
}
out
}
fn trailing_comment(line: &str) -> Option<&str> {
let after_eq = line.find('=')? + 1;
let mut in_string = false;
let mut escaped = false;
for (i, c) in line.char_indices().skip_while(|(i, _)| *i < after_eq) {
match c {
_ if escaped => escaped = false,
'\\' if in_string => escaped = true,
'"' => in_string = !in_string,
'#' if !in_string => return Some(&line[i..]),
_ => {}
}
}
None
}
pub fn merge_toml(existing: &str, answers: &[(&SettingSpec, Value)]) -> String {
if existing.trim().is_empty() {
return render_toml(answers);
}
let mut out = String::new();
let mut section: Option<String> = None;
let mut replaced = vec![false; answers.len()];
for line in existing.lines() {
let trimmed = line.trim();
if let Some(name) = trimmed.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')) {
section = Some(name.trim().to_string());
out.push_str(line);
out.push('\n');
continue;
}
let key = match trimmed.split_once('=') {
Some((key, _)) if !trimmed.starts_with('#') => key.trim(),
_ => {
out.push_str(line);
out.push('\n');
continue;
}
};
match answers.iter().position(|(spec, _)| spec.key == key && spec.section == section.as_deref()) {
Some(i) => {
replaced[i] = true;
let comment = trailing_comment(line)
.map(|c| format!(" {c}"))
.unwrap_or_default();
out.push_str(&format!(
"{key} = {}{comment}\n",
toml_value(&answers[i].1)
));
}
None => {
out.push_str(line);
out.push('\n');
}
}
}
for (i, (setting, value)) in answers.iter().enumerate() {
if replaced[i] {
continue;
}
let line = format!("{} = {}", setting.key, toml_value(value));
out = match setting.section {
None => insert_top_level(&out, &line),
Some(section) => insert_into_section(&out, section, &line),
};
}
out
}
fn insert_into_section(toml: &str, section: &str, line: &str) -> String {
let header = format!("[{section}]");
match toml.lines().position(|l| l.trim() == header) {
Some(i) => {
let mut lines: Vec<&str> = toml.lines().collect();
lines.insert(i + 1, line);
format!("{}\n", lines.join("\n"))
}
None => format!("{toml}\n{header}\n{line}\n"),
}
}
pub fn current_toml_values(tool: &ConfigurableTool, existing: &str) -> Vec<Option<Value>> {
let Ok(doc) = toml::from_str::<toml::Table>(existing) else {
return tool.settings.iter().map(|_| None).collect();
};
tool.settings
.iter()
.map(|setting| {
let value = match setting.section {
Some(section) => doc.get(section)?.as_table()?.get(setting.key)?,
None => doc.get(setting.key)?,
};
toml_to_json(value)
})
.collect()
}
fn toml_to_json(value: &toml::Value) -> Option<Value> {
match value {
toml::Value::Boolean(b) => Some(json!(b)),
toml::Value::Integer(i) => Some(json!(i)),
toml::Value::String(s) => Some(json!(s)),
_ => None,
}
}
pub fn default_toml(tool_key: &str, overrides: &[(&str, &str)]) -> Option<String> {
let tool = find(tool_key)?;
if !matches!(tool.target, ConfigTarget::ProjectToml { .. }) {
return None;
}
let answers: Vec<(&SettingSpec, Value)> = tool
.settings
.iter()
.map(|setting| {
let value = overrides
.iter()
.find(|(key, _)| *key == setting.key)
.map(|(_, v)| json!(v))
.unwrap_or_else(|| setting.kind.default_value());
(setting, value)
})
.collect();
Some(render_toml(&answers))
}
pub fn insert_top_level(toml: &str, line: &str) -> String {
if toml.starts_with('[') {
return format!("{line}\n\n{toml}");
}
match toml.find("\n[") {
Some(i) => format!("{}{line}\n\n{}", &toml[..=i], &toml[i + 1..]),
None => format!("{toml}{line}\n"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const A_TOP_LEVEL: SettingSpec = SettingSpec {
key: "indent_type",
description: "for the tests below",
section: None,
kind: SettingKind::Choice { default: "Tabs", options: &[] },
};
#[test]
fn a_trailing_comment_on_a_setting_line_survives() {
let existing = "indent_type = \"Tabs\" # agreed with the team\n";
let merged = merge_toml(existing, &[(&A_TOP_LEVEL, json!("Tabs"))]);
assert_eq!(merged, existing, "same value in, same bytes out");
}
#[test]
fn a_trailing_comment_survives_a_changed_value() {
let merged = merge_toml(
"indent_type = \"Tabs\" # agreed with the team\n",
&[(&A_TOP_LEVEL, json!("Spaces"))],
);
assert_eq!(merged, "indent_type = \"Spaces\" # agreed with the team\n");
}
#[test]
fn a_hash_inside_a_string_value_is_not_a_comment() {
assert_eq!(trailing_comment("key = \"a # b\""), None);
assert_eq!(trailing_comment("key = \"a\" # b"), Some("# b"));
assert_eq!(trailing_comment("key = \"a \\\" # b\""), None);
assert_eq!(trailing_comment("key = 1"), None);
}
#[test]
fn a_quote_in_a_value_produces_parseable_toml() {
let merged = merge_toml("", &[(&A_TOP_LEVEL, json!("a\"b\\c"))]);
let parsed: toml::Table = toml::from_str(&merged).expect("valid TOML");
assert_eq!(parsed["indent_type"].as_str(), Some("a\"b\\c"));
}
#[test]
fn control_characters_are_escaped_too() {
assert_eq!(escape_toml_string("a\nb\tc"), "a\\nb\\tc");
assert_eq!(escape_toml_string("a\u{1}b"), "a\\u0001b");
assert_eq!(escape_toml_string("plain"), "plain");
}
#[test]
fn scaffolded_defaults_match_configure_defaults() {
for tool in CONFIGURABLE_TOOLS {
let ConfigTarget::ProjectToml { .. } = tool.target else { continue };
let scaffolded = default_toml(tool.key, &[]).expect("project-toml tool renders");
let answers: Vec<(&SettingSpec, Value)> =
tool.settings.iter().map(|s| (s, s.kind.default_value())).collect();
assert_eq!(scaffolded, render_toml(&answers), "{} diverged", tool.key);
}
}
#[test]
fn stylua_defaults_select_luau_syntax() {
let toml = default_toml("stylua", &[]).unwrap();
assert!(toml.contains(r#"syntax = "Luau""#), "{toml}");
}
#[test]
fn selene_std_can_be_overridden_for_testez() {
let plain = default_toml("selene", &[]).unwrap();
assert!(plain.contains(r#"std = "roblox""#), "{plain}");
let testez = default_toml("selene", &[("std", "roblox+testez")]).unwrap();
assert!(testez.contains(r#"std = "roblox+testez""#), "{testez}");
}
#[test]
fn top_level_keys_precede_any_section_header() {
let toml = default_toml("selene", &[]).unwrap();
let first_header = toml.find('[').expect("selene has a [rules] section");
assert!(
toml[..first_header].contains("std ="),
"std must appear before the first header:\n{toml}"
);
}
#[test]
fn inserted_keys_land_above_the_first_section() {
let base = default_toml("selene", &[]).unwrap();
let with_exclude = insert_top_level(&base, r#"exclude = ["modules/submodules/**"]"#);
let first_header = with_exclude.find("
[").expect("selene has a [rules] section");
assert!(with_exclude[..first_header].contains("exclude ="), "{with_exclude}");
assert!(with_exclude.contains("[rules]"), "sections must survive:
{with_exclude}");
assert!(
with_exclude.contains("
[rules]"),
"the header should keep a blank line above it:
{with_exclude}"
);
}
#[test]
fn inserted_keys_work_without_any_section() {
let out = insert_top_level("std = \"roblox\"
", "exclude = []");
assert!(out.contains("exclude = []"), "{out}");
}
fn enter_through(tool: &'static ConfigurableTool, existing: &str) -> Vec<(&'static SettingSpec, Value)> {
let current = current_toml_values(tool, existing);
tool.settings
.iter()
.zip(current)
.map(|(spec, current)| (spec, current.unwrap_or_else(|| spec.kind.default_value())))
.collect()
}
#[test]
fn merging_keeps_keys_the_catalog_does_not_describe() {
let tool = find("selene").unwrap();
let existing = default_toml("selene", &[]).unwrap();
let existing = insert_top_level(&existing, r#"exclude = ["Packages/**"]"#);
let merged = merge_toml(&existing, &enter_through(tool, &existing));
assert!(merged.contains(r#"exclude = ["Packages/**"]"#), "{merged}");
}
#[test]
fn enter_through_configure_changes_nothing() {
for tool in CONFIGURABLE_TOOLS {
let ConfigTarget::ProjectToml { .. } = tool.target else { continue };
let mut existing = default_toml(tool.key, &[]).unwrap();
existing = insert_top_level(&existing, "# hand-written\nunmanaged_top = 7");
existing.push_str("\n[unmanaged_table]\nkey = \"value\"\n");
let merged = merge_toml(&existing, &enter_through(tool, &existing));
assert_eq!(merged, existing, "{} rewrote its config", tool.key);
}
}
#[test]
fn merging_rewrites_only_the_answered_line() {
let tool = find("selene").unwrap();
let existing = "# keep me\nstd = \"roblox\"\nexclude = [\"Packages/**\"]\n\n[rules]\nshadowing = \"warn\"\n";
let mut answers = enter_through(tool, existing);
for (spec, value) in answers.iter_mut() {
if spec.key == "shadowing" {
*value = json!("allow");
}
}
let merged = merge_toml(existing, &answers);
assert!(merged.contains("# keep me"), "{merged}");
assert!(merged.contains(r#"exclude = ["Packages/**"]"#), "{merged}");
assert!(merged.contains(r#"shadowing = "allow""#), "{merged}");
assert!(!merged.contains(r#"shadowing = "warn""#), "{merged}");
}
#[test]
fn a_missing_sectioned_setting_lands_under_its_own_header() {
let tool = find("selene").unwrap();
let existing = "std = \"roblox\"\n";
let merged = merge_toml(existing, &enter_through(tool, existing));
let parsed = toml::from_str::<toml::Table>(&merged).expect("still valid TOML");
assert_eq!(parsed["rules"]["shadowing"].as_str(), Some("warn"), "{merged}");
assert_eq!(parsed["std"].as_str(), Some("roblox"), "{merged}");
}
#[test]
fn merging_into_nothing_renders_the_defaults() {
let tool = find("stylua").unwrap();
let answers = enter_through(tool, "");
assert_eq!(merge_toml("", &answers), default_toml("stylua", &[]).unwrap());
}
#[test]
fn current_values_come_from_the_file_not_the_catalog() {
let tool = find("selene").unwrap();
let existing = "std = \"roblox+testez\"\n\nexclude = [\"Packages/**\"]\n\n[rules]\nunused_variable = \"allow\"\n";
let current = current_toml_values(tool, existing);
assert_eq!(current.len(), tool.settings.len());
let by_key = |key: &str, section: Option<&str>| {
let i = tool
.settings
.iter()
.position(|s| s.key == key && s.section == section)
.expect("setting exists");
current[i].clone()
};
assert_eq!(by_key("std", None), Some(json!("roblox+testez")));
assert_eq!(by_key("unused_variable", Some("rules")), Some(json!("allow")));
assert_eq!(by_key("shadowing", Some("rules")), None);
}
#[test]
fn every_configurable_tool_is_findable_by_key() {
for tool in CONFIGURABLE_TOOLS {
assert!(find(tool.key).is_some(), "{} not findable", tool.key);
}
}
}