diff --git a/crates/workshop-rs/src/settings/check.rs b/crates/workshop-rs/src/settings/check.rs index 2c54c0b..7d91d36 100644 --- a/crates/workshop-rs/src/settings/check.rs +++ b/crates/workshop-rs/src/settings/check.rs @@ -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 @@ -102,7 +103,7 @@ pub(crate) fn uncatalogued_members(settings: &Settings) -> Vec { + (SettingsNode::Raw { .. }, _) | (SettingsNode::Group { .. }, _) => { UncataloguedMember { name: node.name(), span: node.span(), @@ -333,9 +334,10 @@ fn check_member(node: &SettingsNode, path: &[PathPart<'_>], errors: &mut Vec 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); } diff --git a/crates/workshop-rs/src/settings/emitter.rs b/crates/workshop-rs/src/settings/emitter.rs index c353fde..2c6032c 100644 --- a/crates/workshop-rs/src/settings/emitter.rs +++ b/crates/workshop-rs/src/settings/emitter.rs @@ -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); } _ => {} } diff --git a/crates/workshop-rs/src/settings/parser.rs b/crates/workshop-rs/src/settings/parser.rs index f7d43c7..909b536 100644 --- a/crates/workshop-rs/src/settings/parser.rs +++ b/crates/workshop-rs/src/settings/parser.rs @@ -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)) { @@ -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(), diff --git a/crates/workshop-rs/tests/settings_pipeline.rs b/crates/workshop-rs/tests/settings_pipeline.rs index 8ea6532..13e4933 100644 --- a/crates/workshop-rs/tests/settings_pipeline.rs +++ b/crates/workshop-rs/tests/settings_pipeline.rs @@ -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 diff --git a/docs/language-support/settings.md b/docs/language-support/settings.md index dc01804..a1f4e6e 100644 --- a/docs/language-support/settings.md +++ b/docs/language-support/settings.md @@ -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`.