From 7cd08fdbb05e7f3c4a161a61e1427df4a0201003 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 10 Oct 2026 02:57:03 +0800 Subject: [PATCH 1/2] feat(settings): carry blocks under catalogued keys as written A braced block under a catalogued non-list settings key carries an undeclared value the same way an uncatalogued-key block does: the parser produces an opaque Group under the canonical key, the emitter writes it under the localized display name, and check accepts it with opaque leaf checking. The pinned source compilers emit this shape (opy-rs#496), so emission gains nothing the parser cannot read back. Fixes #412 --- crates/workshop-rs/src/settings/check.rs | 9 +- crates/workshop-rs/src/settings/emitter.rs | 12 ++- crates/workshop-rs/src/settings/parser.rs | 7 ++ crates/workshop-rs/tests/settings_pipeline.rs | 100 ++++++++++++++++++ 4 files changed, 121 insertions(+), 7 deletions(-) diff --git a/crates/workshop-rs/src/settings/check.rs b/crates/workshop-rs/src/settings/check.rs index 2c54c0b..da8fd35 100644 --- a/crates/workshop-rs/src/settings/check.rs +++ b/crates/workshop-rs/src/settings/check.rs @@ -102,7 +102,7 @@ pub(crate) fn uncatalogued_members(settings: &Settings) -> Vec { + (SettingsNode::Raw { .. }, _) | (SettingsNode::Group { .. }, _) => { UncataloguedMember { name: node.name(), span: node.span(), @@ -333,9 +333,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..8a410bd 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)) { diff --git a/crates/workshop-rs/tests/settings_pipeline.rs b/crates/workshop-rs/tests/settings_pipeline.rs index 8ea6532..119fbbe 100644 --- a/crates/workshop-rs/tests/settings_pipeline.rs +++ b/crates/workshop-rs/tests/settings_pipeline.rs @@ -1602,6 +1602,106 @@ 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 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 From bd0633b415118091373823f7f1872de61189d3ee Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 10 Oct 2026 03:46:13 +0800 Subject: [PATCH 2/2] docs(settings): record carried blocks under catalogued keys Address PR review findings: document the new carried shape in the settings language-support page and the check module doc, assert the emitted text reparses, validates, and re-emits identically in the #412 round-trip test, and mark the non-list brace arm unreachable. Refs #412 --- crates/workshop-rs/src/settings/check.rs | 5 +++-- crates/workshop-rs/src/settings/parser.rs | 6 +----- crates/workshop-rs/tests/settings_pipeline.rs | 7 +++++++ docs/language-support/settings.md | 3 +++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/workshop-rs/src/settings/check.rs b/crates/workshop-rs/src/settings/check.rs index da8fd35..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 diff --git a/crates/workshop-rs/src/settings/parser.rs b/crates/workshop-rs/src/settings/parser.rs index 8a410bd..909b536 100644 --- a/crates/workshop-rs/src/settings/parser.rs +++ b/crates/workshop-rs/src/settings/parser.rs @@ -273,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 119fbbe..13e4933 100644 --- a/crates/workshop-rs/tests/settings_pipeline.rs +++ b/crates/workshop-rs/tests/settings_pipeline.rs @@ -1637,6 +1637,13 @@ rule("r") 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 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`.