Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions crates/workshop-rs/src/settings/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
//! [`crate::Program::validate`] runs [`check_emission`] on every program, so
//! checking raw Workshop input reports the same settings errors emission
//! would. Members the catalog does not declare are carried as written
//! ([`SettingsNode::Raw`], [`SettingsNode::RawValue`], or a block of them) and
//! accepted; [`check_emission`] reports every other member the emitter would
//! ([`SettingsNode::Raw`], [`SettingsNode::RawValue`], or a block of them —
//! under an unknown key or a catalogued non-list key) and accepted;
//! [`check_emission`] reports every other member the emitter would
//! reject, without producing Workshop text. A carried member close to
//! exactly one declared spelling is additionally reported as a
//! [`DiagnosticSeverity::Warning`] diagnostic — a likely misspelling — by
Expand Down Expand Up @@ -102,7 +103,7 @@ pub(crate) fn uncatalogued_members(settings: &Settings) -> Vec<UncataloguedMembe
full.push(PathPart::Part(node.name()));
let catalogued = table::lookup(&full);
let member = match (node, catalogued) {
(SettingsNode::Raw { .. }, _) | (SettingsNode::Group { .. }, None) => {
(SettingsNode::Raw { .. }, _) | (SettingsNode::Group { .. }, _) => {
UncataloguedMember {
name: node.name(),
span: node.span(),
Expand Down Expand Up @@ -333,9 +334,10 @@ fn check_member(node: &SettingsNode, path: &[PathPart<'_>], errors: &mut Vec<Set
}
match (node, table::lookup(&full)) {
(SettingsNode::Raw { .. } | SettingsNode::RawValue { .. }, _) => return,
// Mirrors `settings_member`: an uncatalogued block is emitted as
// written; only its leaves must have a written form.
(SettingsNode::Group { children, .. }, None) => {
// Mirrors `settings_member`: a block is carried as written whether
// or not its key is catalogued; only its leaves must have a written
// form (#412).
(SettingsNode::Group { children, .. }, _) => {
for child in children {
check_member(child, &[], errors);
}
Expand Down
12 changes: 9 additions & 3 deletions crates/workshop-rs/src/settings/emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,15 @@ impl EmitContext<'_> {
self.line(level, &format!("{display_name}: {value}"))
};
}
// A block the catalog does not declare is carried as written.
(SettingsNode::Group { children, .. }, None) => {
return self.emit_opaque_group(children, name, level);
// A block is carried as written: under an uncatalogued key
// verbatim, and under a catalogued key as an undeclared value
// with the key's display name (#412).
(SettingsNode::Group { children, .. }, entry) => {
let block_name = match entry {
Some(entry) => self.member_display_name(&full, hero, entry)?,
None => name.to_string(),
};
return self.emit_opaque_group(children, &block_name, level);
}
_ => {}
}
Expand Down
13 changes: 8 additions & 5 deletions crates/workshop-rs/src/settings/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,13 @@ impl ParseContext<'_> {
_ => return Err(self.malformed("settings entry has no leaf key", self.previous())),
};
if matches!(self.peek().map(|token| token.kind), Some(TokenKind::LBrace)) {
// A block under a catalogued non-list key carries an undeclared
// value: the pinned source compilers write it as an opaque
// block, so it parses like an uncatalogued-key block (#412).
if !matches!(entry.kind, KeyKind::ListMap | KeyKind::ListHero) {
self.pos += 1;
return self.settings_opaque_group(name, start);
}
self.expect(TokenKind::LBrace, "expected '{' after settings list")?;
let mut elements = Vec::new();
while !matches!(self.peek().map(|token| token.kind), Some(TokenKind::RBrace)) {
Expand Down Expand Up @@ -266,11 +273,7 @@ impl ParseContext<'_> {
&value,
)
.unwrap_or(value.as_str()),
_ => {
return Err(
self.malformed("only settings lists may use braces", self.previous())
);
}
_ => unreachable!("non-list kinds return an opaque group above"),
};
elements.push(SettingsListElement {
value: canonical.to_string(),
Expand Down
107 changes: 107 additions & 0 deletions crates/workshop-rs/tests/settings_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,113 @@ fn typed_uncatalogued_members_localize_catalogued_keys_only() {
assert!(localized.contains(": Sometimes"), "{localized}");
}

#[test]
fn issue_412_catalogued_key_block_round_trips_as_written() {
// A block under a catalogued non-list key carries an undeclared value:
// the pinned source compilers emit this shape, so parse, check, and
// emit treat it as an opaque block under the key's display name.
let source = r#"settings
{
lobby
{
Map Rotation
{
a
b
}
}
}
rule("r")
{
event
{
Ongoing - Global;
}
actions
{
Wait(1, Ignore Condition);
}
}
"#;
let program = parser::parse(source, &catalog(), &en()).expect("source parses");
program.validate().expect("a carried block is accepted");
let emitted = emitter::emit(&program, &catalog(), &en()).expect("emits");
let lines: Vec<&str> = emitted.lines().map(str::trim).collect();
for line in ["Map Rotation {", "a", "b"] {
assert!(lines.contains(&line), "{line:?} in {emitted}");
}
// The emitted text reparses, validates, and re-emits identically.
let reparsed = parser::parse(&emitted, &catalog(), &en()).expect("emitted text reparses");
reparsed.validate().expect("emitted text validates");
assert_eq!(
emitter::emit(&reparsed, &catalog(), &en()).expect("re-emits"),
emitted
);
// The carried block is a residual under its canonical key, not a
// misspelling.
let issue = program
.semantic_issues(&catalog())
.into_iter()
.find(|issue| issue.name == "mapRotation")
.expect("the carried block is a residual");
assert_eq!(issue.suggestion, None);
assert_eq!(
issue.classification,
semantic::ResidualClassification::ProjectDefinedConstruct
);
assert_check_compile_parity(source);
}

#[test]
fn issue_412_catalogued_key_block_emits_the_display_name() {
// A `SettingsNode::Group` under a catalogued key writes the localized
// display name with opaque children; an uncatalogued key keeps its
// authored name.
use workshop_rs::settings::{Settings, SettingsNode};

let raw = |name: &str| SettingsNode::Raw {
name: name.into(),
value: String::new(),
span: None,
};
let mut program = workshop_rs::Program::new();
program.settings = Some(Settings {
span: None,
children: vec![
SettingsNode::Group {
name: "lobby".into(),
children: vec![
SettingsNode::Group {
name: "mapRotation".into(),
children: vec![raw("a"), raw("b")],
span: None,
},
SettingsNode::Group {
name: "team1Slots".into(),
children: vec![raw("x")],
span: None,
},
],
span: None,
},
SettingsNode::Group {
name: "gamemodes".into(),
children: Vec::new(),
span: None,
},
],
});
program.validate().expect("carried blocks are accepted");
let emitted = emitter::emit(&program, &catalog(), &en()).expect("emits");
let lines: Vec<&str> = emitted.lines().map(str::trim).collect();
for line in ["Map Rotation {", "a", "b", "Max Team 1 Players {", "x"] {
assert!(lines.contains(&line), "{line:?} in {emitted}");
}
let localized = emitter::emit(&program, &catalog(), &zh()).expect("emits zh-CN");
assert!(!localized.contains("mapRotation"), "{localized}");
assert!(!localized.contains("Max Team 1 Players"), "{localized}");
}

#[test]
fn issue_360_opaque_settings_remain_verbatim() {
// Unrecognized leaf members and `settings.workshop` payloads stay
Expand Down
3 changes: 3 additions & 0 deletions docs/language-support/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ rejected, so a project can keep settings the catalog has not caught up with
or a bare `name`);
- a braced block whose key is unknown parses to a `SettingsNode::Group` of
such leaves, emitted as the same block;
- a braced block under a catalogued non-list key also parses to a
`SettingsNode::Group`: the key emits under its display name (localized)
while the block contents stay as written;
- a catalogued key whose value is not a declared enum member, and an unknown
`name:` with nothing after the colon, parse to `SettingsNode::RawValue`.

Expand Down
Loading