use super::*;
use workshop_rs::source::{Position as WorkshopPosition, Span as WorkshopSpan};
pub(crate) fn workshop_settings(
hir: &crate::hir::Program,
) -> Result<(Option<workshop_rs::settings::Settings>, Vec<UnknownSetting>), IntegrationError> {
let settings_constants: HashMap<String, &Expr> = hir
.declarations
.iter()
.filter_map(|declaration| match declaration {
hir::Declaration::Constant { name, value, .. } => Some((name.clone(), value.as_ref())),
_ => None,
})
.collect();
let mut settings = merge_extensions(
hir.settings
.clone()
.map(|settings| expand_settings_constants(settings, &settings_constants)),
&hir.preprocessing.directives,
)?;
let unknown = settings
.as_mut()
.map(pass_through_unknown_members)
.transpose()?
.unwrap_or_default();
Ok((settings, unknown))
}
pub(crate) struct UnknownSetting {
pub(crate) name: String,
pub(crate) value: Option<String>,
pub(crate) span: Option<WorkshopSpan>,
}
fn hero_setting_applies(hero: &str, key: &str) -> bool {
let data = applicability();
if data.all.iter().any(|candidate| candidate == key) {
return true;
}
if let Some(heroes) = data.only.get(key) {
return heroes.iter().any(|candidate| candidate == hero);
}
if let Some(heroes) = data.except.get(key) {
return !heroes.iter().any(|candidate| candidate == hero);
}
false
}
#[derive(serde::Deserialize)]
struct HeroApplicability {
all: Vec<String>,
only: std::collections::HashMap<String, Vec<String>>,
except: std::collections::HashMap<String, Vec<String>>,
}
fn applicability() -> &'static HeroApplicability {
static DATA: std::sync::OnceLock<HeroApplicability> = std::sync::OnceLock::new();
DATA.get_or_init(|| {
serde_json::from_str(include_str!("data/hero_applicability.json"))
.expect("hero applicability data is valid JSON")
})
}
const HERO_NAME_ALIASES: &[(&str, &str)] = &[("mccree", "cassidy"), ("hammond", "wreckingBall")];
fn canonical_hero_name(name: &str) -> &str {
HERO_NAME_ALIASES
.iter()
.find_map(|(alias, canonical)| (*alias == name).then_some(*canonical))
.unwrap_or(name)
}
fn rename_member(member: &mut workshop_rs::settings::SettingsNode, name: &str) {
use workshop_rs::settings::SettingsNode;
match member {
SettingsNode::Group { name: slot, .. }
| SettingsNode::Number { name: slot, .. }
| SettingsNode::Bool { name: slot, .. }
| SettingsNode::Flag { name: slot, .. }
| SettingsNode::String { name: slot, .. }
| SettingsNode::List { name: slot, .. }
| SettingsNode::Raw { name: slot, .. }
| SettingsNode::RawValue { name: slot, .. } => *slot = name.to_string(),
SettingsNode::Workshop { .. } => {}
}
}
fn pass_through_unknown_members(
settings: &mut workshop_rs::settings::Settings,
) -> Result<Vec<UnknownSetting>, IntegrationError> {
use workshop_rs::settings::{PathPart, SettingsNode};
let mut unknown = Vec::new();
for group in &mut settings.children {
let SettingsNode::Group { name, children, .. } = group else {
continue;
};
match name.as_str() {
"main" | "lobby" => {
pass_through_members(children, &[PathPart::Part(name)], &mut unknown, None);
}
"gamemodes" => {
for mode in children {
if let SettingsNode::Group { name, children, .. } = mode {
children.retain(|member| {
member.name() != "enabled"
|| matches!(member, SettingsNode::Bool { .. })
});
let path = [PathPart::Part("gamemodes"), PathPart::Part(name)];
pass_through_members(children, &path, &mut unknown, None);
}
}
}
"heroes" => {
for team in children {
let SettingsNode::Group {
name: team_name,
children,
..
} = team
else {
continue;
};
let mut general = Vec::new();
let mut rest = Vec::new();
for child in std::mem::take(children) {
match child {
SettingsNode::Group { name, children, .. } if name == "general" => {
general.extend(children);
}
child if child.name() == "general" => match child {
SettingsNode::String { value, span, .. } => {
general.extend(value.chars().enumerate().map(|(index, ch)| {
SettingsNode::Raw {
name: index.to_string(),
value: ch.to_string(),
span,
}
}));
}
SettingsNode::List { elements, .. } => {
general.extend(elements.iter().enumerate().map(
|(index, element)| SettingsNode::Raw {
name: index.to_string(),
value: element.value.clone(),
span: element.span,
},
));
}
_ => {}
},
child => rest.push(child),
}
}
let team_path = [PathPart::Part("heroes"), PathPart::Team];
pass_through_members(&mut general, &team_path, &mut unknown, None);
let mut enabled = false;
let mut disabled = false;
for child in &rest {
enabled |= child.name() == "enabledHeroes";
disabled |= child.name() == "disabledHeroes";
}
if enabled && disabled {
return Err(IntegrationError::new(
"settings-hero-lists",
format!(
"Cannot have both 'enabledHeroes' and 'disabledHeroes' in team '{team_name}'"
),
None,
));
}
let (mut hero_lists, mut rest): (Vec<_>, Vec<_>) =
rest.into_iter().partition(|child| {
matches!(child, SettingsNode::List { name, .. } if matches!(
name.as_str(),
"enabledHeroes" | "disabledHeroes"
))
});
for (alias, canonical) in HERO_NAME_ALIASES {
let mut index = 0;
while index < rest.len() {
let SettingsNode::Group { name, .. } = &rest[index] else {
index += 1;
continue;
};
if name != alias {
index += 1;
continue;
}
let mut moved = rest.remove(index);
if let SettingsNode::Group { name, .. } = &mut moved {
*name = (*canonical).to_string();
}
match rest.iter_mut().find(|m| m.name() == *canonical) {
Some(dest) => *dest = moved,
None => rest.push(moved),
}
}
}
for hero in rest.iter_mut().chain(hero_lists.iter_mut()) {
match hero {
SettingsNode::Group { name, children, .. } => {
let canonical = canonical_hero_name(name).to_string();
if let Some(source) =
children.iter().position(|m| m.name() == "ability1KB%")
{
let mut moved = children.remove(source);
rename_member(&mut moved, "ability1Kb%");
match children.iter_mut().find(|m| m.name() == "ability1Kb%") {
Some(dest) => *dest = moved,
None => children.push(moved),
}
}
let path =
[PathPart::Part("heroes"), PathPart::Team, PathPart::Hero];
pass_through_members(
children,
&path,
&mut unknown,
Some(canonical.as_str()),
);
}
SettingsNode::List { name, elements, .. }
if matches!(name.as_str(), "enabledHeroes" | "disabledHeroes") =>
{
for element in elements {
let canonical = canonical_hero_name(&element.value);
if canonical != element.value {
element.value = canonical.to_string();
}
}
}
_ => {}
}
}
general.extend(rest);
general.extend(hero_lists);
*children = general;
}
}
_ => {}
}
}
Ok(unknown)
}
fn pass_through_members(
members: &mut [workshop_rs::settings::SettingsNode],
path: &[workshop_rs::settings::PathPart<'_>],
unknown: &mut Vec<UnknownSetting>,
hero: Option<&str>,
) {
use workshop_rs::settings::{PathPart, SettingValueDomain, SettingsNode};
for member in members {
if member.name() == "enabled" && matches!(path, [PathPart::Part("gamemodes"), _]) {
continue;
}
let name = member.name().to_string();
let span = member.span();
let mut full = path.to_vec();
full.push(PathPart::Part(&name));
let definition = workshop_rs::settings::definition(&full);
let inapplicable = definition.is_some()
&& hero.is_some_and(|hero| !hero_setting_applies(hero, name.as_str()));
match definition.filter(|_| !inapplicable) {
None => {
if matches!(member, SettingsNode::Raw { .. }) {
continue;
}
*member = if inapplicable {
verbatim_form(member.clone())
} else {
written_form(member.clone())
};
unknown.push(UnknownSetting {
name,
value: None,
span,
});
}
Some(definition) => {
if !matches!(definition.domain(), SettingValueDomain::Enum { .. }) {
continue;
}
let value = match member {
SettingsNode::String { value, .. }
if !definition.enum_members().any(|member| member.id() == value) =>
{
value.clone()
}
SettingsNode::Number { .. } | SettingsNode::Bool { .. } => {
scalar_text(member).expect("scalar member")
}
_ => continue,
};
*member = SettingsNode::RawValue {
name: name.clone(),
value: value.clone(),
span,
};
unknown.push(UnknownSetting {
name,
value: Some(value),
span,
});
}
}
}
}
fn written_form(node: workshop_rs::settings::SettingsNode) -> workshop_rs::settings::SettingsNode {
use workshop_rs::settings::SettingsNode;
match node {
SettingsNode::List {
name,
elements,
span,
} => SettingsNode::Group {
name,
children: elements
.into_iter()
.map(|element| SettingsNode::Raw {
name: element.value,
value: String::new(),
span: element.span,
})
.collect(),
span,
},
SettingsNode::Group {
name,
children,
span,
} => SettingsNode::Group {
name,
children: children.into_iter().map(written_form).collect(),
span,
},
node => match scalar_text(&node) {
Some(value) => SettingsNode::RawValue {
name: node.name().to_string(),
value,
span: node.span(),
},
None => node,
},
}
}
fn verbatim_form(node: workshop_rs::settings::SettingsNode) -> workshop_rs::settings::SettingsNode {
use workshop_rs::settings::SettingsNode;
match scalar_text(&node) {
Some(value) => SettingsNode::Raw {
name: node.name().to_string(),
value,
span: node.span(),
},
None => written_form(node),
}
}
fn scalar_text(node: &workshop_rs::settings::SettingsNode) -> Option<String> {
use workshop_rs::settings::SettingsNode;
match node {
SettingsNode::Number { value, .. } => Some(super::number_format::javascript_text(*value)),
SettingsNode::Bool { value, .. } => Some(value.to_string()),
SettingsNode::String { value, .. } => Some(value.clone()),
_ => None,
}
}
pub(super) fn merge_extensions(
settings: Option<crate::hir::Settings>,
directives: &[crate::hir::DirectiveRecord],
) -> Result<Option<workshop_rs::settings::Settings>, IntegrationError> {
let mut extension_nodes: Vec<workshop_rs::settings::SettingsNode> = Vec::new();
for directive in directives
.iter()
.filter(|directive| directive.name == "extension")
{
let Some(name) = directive.value.as_deref() else {
return Err(IntegrationError::new(
"directive-invalid",
"`#!extension` expects one argument",
directive.span,
));
};
let span = directive.span;
if extension_nodes.iter().any(|node| node.name() == name) {
continue;
}
let path = [
workshop_rs::settings::PathPart::Part("extensions"),
workshop_rs::settings::PathPart::Part(name),
];
let Some(definition) = workshop_rs::settings::definition(&path) else {
return Err(IntegrationError::new(
"directive-invalid",
format!("unknown Workshop extension `{name}`"),
span,
));
};
if !matches!(
definition.domain(),
workshop_rs::settings::SettingValueDomain::PresenceOnly
) {
return Err(IntegrationError::new(
"directive-invalid",
format!("Workshop extension `{name}` is not a presence-only setting"),
span,
));
}
extension_nodes.push(workshop_rs::settings::SettingsNode::Flag {
name: name.to_string(),
span: span.map(convert_settings_span),
});
}
if extension_nodes.is_empty() {
return Ok(settings.map(convert_settings));
}
let mut settings = settings
.map(convert_settings)
.unwrap_or(workshop_rs::settings::Settings {
span: None,
children: Vec::new(),
});
if let Some(workshop_rs::settings::SettingsNode::Group { children, .. }) = settings
.children
.iter_mut()
.find(|node| node.name() == "extensions")
{
for extension in extension_nodes {
if !children.iter().any(|node| node.name() == extension.name()) {
children.push(extension);
}
}
} else {
settings
.children
.push(workshop_rs::settings::SettingsNode::Group {
name: "extensions".to_string(),
children: extension_nodes,
span: None,
});
}
Ok(Some(settings))
}
pub(super) fn expand_settings_constants(
settings: crate::hir::Settings,
constants: &HashMap<String, &Expr>,
) -> crate::hir::Settings {
crate::hir::Settings {
span: settings.span,
children: settings
.children
.into_iter()
.map(|node| expand_settings_node(node, constants))
.collect(),
}
}
fn expand_settings_node(
node: crate::hir::SettingsNode,
constants: &HashMap<String, &Expr>,
) -> crate::hir::SettingsNode {
use crate::hir::SettingsNode;
match node {
SettingsNode::Group {
name,
children,
span,
} => {
let children = children
.into_iter()
.map(|child| expand_settings_node(child, constants))
.collect::<Vec<_>>();
SettingsNode::Group {
name,
children,
span,
}
}
SettingsNode::Raw { name, value, span } => constants
.get(&value)
.and_then(|expr| settings_node_from_expr(name.clone(), expr, constants, span))
.unwrap_or(SettingsNode::Raw { name, value, span }),
node => node,
}
}
fn settings_node_from_expr(
name: String,
expr: &Expr,
constants: &HashMap<String, &Expr>,
span: Option<HirSpan>,
) -> Option<crate::hir::SettingsNode> {
use crate::hir::SettingsNode;
match expr {
Expr::Constant { name: value, .. } => constants
.get(value)
.and_then(|expr| settings_node_from_expr(name, expr, constants, span)),
Expr::Dict { entries, .. } => Some(SettingsNode::Group {
name,
children: entries
.iter()
.filter_map(|entry| {
let Expr::String {
value: child_name, ..
} = entry.key.as_ref()
else {
return None;
};
settings_node_from_expr(child_name.clone(), &entry.value, constants, entry.span)
})
.collect(),
span,
}),
Expr::Number { value, .. } => Some(SettingsNode::Number {
name,
value: *value,
span,
}),
Expr::Bool { value, .. } => Some(SettingsNode::Bool {
name,
value: *value,
span,
}),
Expr::String { value, .. } => Some(SettingsNode::String {
name,
value: value.clone(),
span,
}),
_ => None,
}
}
pub(super) fn convert_settings(settings: crate::hir::Settings) -> workshop_rs::settings::Settings {
workshop_rs::settings::Settings {
span: settings.span.map(convert_settings_span),
children: settings
.children
.into_iter()
.map(convert_settings_node)
.collect(),
}
}
fn convert_settings_node(node: crate::hir::SettingsNode) -> workshop_rs::settings::SettingsNode {
use crate::hir::SettingsNode as SourceNode;
use workshop_rs::settings::{SettingsListElement, SettingsNode as TargetNode};
match node {
SourceNode::Group {
name,
children,
span,
} if name == "workshop" => TargetNode::Workshop {
children: children.into_iter().map(convert_workshop_node).collect(),
span: span.map(convert_settings_span),
},
SourceNode::Group {
name,
children,
span,
} => TargetNode::Group {
children: children
.into_iter()
.map(|child| escape_main_string(&name, child))
.map(convert_settings_node)
.collect(),
name,
span: span.map(convert_settings_span),
},
SourceNode::Number { name, value, span } => TargetNode::Number {
name,
value,
span: span.map(convert_settings_span),
},
SourceNode::Bool { name, value, span } => TargetNode::Bool {
name,
value,
span: span.map(convert_settings_span),
},
SourceNode::String { name, value, span } => TargetNode::String {
name: name.clone(),
value: if name == "mapRotation" && value == "afterGame" {
"afterAGame".to_string()
} else {
value
},
span: span.map(convert_settings_span),
},
SourceNode::Raw { name, value, span } => TargetNode::Raw {
name,
value,
span: span.map(convert_settings_span),
},
SourceNode::List {
name,
elements,
span,
} => TargetNode::List {
name,
elements: elements
.into_iter()
.map(|element| SettingsListElement {
value: element.value,
span: element.span.map(convert_settings_span),
})
.collect(),
span: span.map(convert_settings_span),
},
}
}
fn escape_main_string(group: &str, node: crate::hir::SettingsNode) -> crate::hir::SettingsNode {
use crate::hir::SettingsNode;
match node {
SettingsNode::String { name, value, span }
if group == "main" && matches!(name.as_str(), "modeName" | "description") =>
{
SettingsNode::String {
value: super::lowering::escape_bad_words(&value),
name,
span,
}
}
node => node,
}
}
fn convert_workshop_node(node: crate::hir::SettingsNode) -> workshop_rs::settings::SettingsNode {
use crate::hir::SettingsNode as SourceNode;
use workshop_rs::settings::SettingsNode as TargetNode;
match node {
SourceNode::Group {
name,
children,
span,
} => TargetNode::Group {
name,
children: children.into_iter().map(convert_workshop_node).collect(),
span: span.map(convert_settings_span),
},
SourceNode::Number { name, value, span } => TargetNode::Raw {
name,
value: number_format::javascript_text(value),
span: span.map(convert_settings_span),
},
SourceNode::Bool { name, value, span } => TargetNode::Raw {
name,
value: value.to_string(),
span: span.map(convert_settings_span),
},
SourceNode::String { name, value, span } | SourceNode::Raw { name, value, span } => {
TargetNode::Raw {
name,
value,
span: span.map(convert_settings_span),
}
}
SourceNode::List {
name,
elements,
span,
} => TargetNode::Raw {
name,
value: format!(
"[{}]",
elements
.into_iter()
.map(|element| element.value)
.collect::<Vec<_>>()
.join(", ")
),
span: span.map(convert_settings_span),
},
}
}
fn convert_settings_span(span: HirSpan) -> WorkshopSpan {
WorkshopSpan::new(
workshop_rs::source::FileId::from_index(span.file as usize),
WorkshopPosition::new(span.start.line, span.start.col),
WorkshopPosition::new(span.end.line, span.end.col),
)
}