Skip to content
Closed
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
130 changes: 130 additions & 0 deletions src/engine/db/koi8r_yaml_emitter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Part of Bylins http://www.mud.ru
// Koi8rYamlEmitter - Custom YAML emitter that preserves KOI8-R encoding.
//
// Scalars whose first character is a YAML indicator (&, *, !, ,, ...) are
// single-quoted, otherwise yaml-cpp interprets the prefix as anchor / alias /
// tag and the save -> reload round-trip breaks (issue #3273: MUD color codes
// like "&Yfoo &Wbar&n" in object/mob names saved unquoted produce
// "cannot assign multiple anchors to the same node" on next boot).

#ifndef KOI8R_YAML_EMITTER_H_
#define KOI8R_YAML_EMITTER_H_

#include <algorithm>
#include <ostream>
#include <regex>
#include <sstream>
#include <string>

class Koi8rYamlEmitter
{
std::ostream &out_;
int indent_;

public:
explicit Koi8rYamlEmitter(std::ostream &out) : out_(out), indent_(0) {}

std::string GetIndent() const { return std::string(indent_, ' '); }

void BeginMap() {}
void EndMap() {}

void Key(const std::string &key) { out_ << GetIndent() << key << ":"; }

void Value(const std::string &value, bool literal = false)
{
if (literal && value.find('\n') != std::string::npos)
{
// Literal block
out_ << " |" << std::endl;

std::string cleaned = value;
cleaned.erase(std::remove(cleaned.begin(), cleaned.end(), '\r'), cleaned.end());

std::istringstream iss(cleaned);
std::string line;
while (std::getline(iss, line))
{
out_ << GetIndent() << " " << line << std::endl;
}
return;
}

// Simple value - quote if contains special YAML characters
bool needs_quoting = value.empty();
if (!value.empty())
{
if (value.find(':') != std::string::npos ||
value.find('#') != std::string::npos ||
value.find('[') != std::string::npos ||
value.find(']') != std::string::npos ||
value.find('{') != std::string::npos ||
value.find('}') != std::string::npos ||
value.find('|') != std::string::npos ||
value.find('>') != std::string::npos ||
value.find('\"') != std::string::npos ||
value.find('\'') != std::string::npos ||
value.find('%') != std::string::npos || // % is YAML directive indicator
value[0] == ' ' || value[0] == '-' || value[0] == '?' ||
value[0] == '@' || value[0] == '`' ||
value[0] == '&' || value[0] == '*' || value[0] == '!' ||
value[0] == ',')
{
needs_quoting = true;
}
}

if (needs_quoting)
{
out_ << " '" << std::regex_replace(value, std::regex("'"), "''") << "'" << std::endl;
}
else
{
out_ << " " << value << std::endl;
}
}

void Value(int value, const std::string &comment = "")
{
out_ << " " << value;
if (!comment.empty()) { out_ << " # " << comment; }
out_ << std::endl;
}

void Value(long value, const std::string &comment = "")
{
out_ << " " << value;
if (!comment.empty()) { out_ << " # " << comment; }
out_ << std::endl;
}

void BeginSequence() { out_ << std::endl; }

void SequenceItem(const std::string &value, const std::string &comment = "")
{
out_ << GetIndent() << "- " << value;
if (!comment.empty()) { out_ << " # " << comment; }
out_ << std::endl;
}

void SequenceItem(int value, const std::string &comment = "")
{
out_ << GetIndent() << "- " << value;
if (!comment.empty()) { out_ << " # " << comment; }
out_ << std::endl;
}

void IncreaseIndent() { indent_ += 2; }
void DecreaseIndent() { indent_ -= 2; }

void Comment(const std::string &text) { out_ << GetIndent() << "# " << text << std::endl; }

void EmptyLine() { out_ << std::endl; }

void BeginBlock() { out_ << std::endl; indent_ += 2; }
void EndBlock() { indent_ -= 2; }
};

#endif // KOI8R_YAML_EMITTER_H_

// vim: ts=4 sw=4 tw=0 noet syntax=cpp :
139 changes: 2 additions & 137 deletions src/engine/db/yaml_world_data_source.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
#include <sstream>
#include <set>

#include "koi8r_yaml_emitter.h"

// External declarations
extern ZoneTable &zone_table;
extern IndexData **trig_index;
Expand All @@ -57,143 +59,6 @@ inline Bitvector IndexToBitvector(long idx)
return static_cast<Bitvector>(flag_data_by_num(static_cast<int>(idx)));
}

// ============================================================================
// Koi8rYamlEmitter - Custom YAML emitter that preserves KOI8-R encoding
// ============================================================================

class Koi8rYamlEmitter {
std::ostream &out_;
int indent_;

public:
Koi8rYamlEmitter(std::ostream &out) : out_(out), indent_(0) {}

std::string GetIndent() const {
return std::string(indent_, ' ');
}

void BeginMap() {
// Maps don't need special output, just increase indent for nested content
}

void EndMap() {
// Nothing to do
}

void Key(const std::string &key) {
out_ << GetIndent() << key << ":";
}

void Value(const std::string &value, bool literal = false) {
if (literal && value.find('\n') != std::string::npos) {
// Literal block
out_ << " |" << std::endl;

// Remove \r, keep \n
std::string cleaned = value;
cleaned.erase(std::remove(cleaned.begin(), cleaned.end(), '\r'), cleaned.end());

std::istringstream iss(cleaned);
std::string line;
while (std::getline(iss, line)) {
out_ << GetIndent() << " " << line << std::endl;
}
} else {
// Simple value - quote if contains special YAML characters
bool needs_quoting = value.empty();

if (!value.empty()) {
// Check for special YAML characters
if (value.find(':') != std::string::npos ||
value.find('#') != std::string::npos ||
value.find('[') != std::string::npos ||
value.find(']') != std::string::npos ||
value.find('{') != std::string::npos ||
value.find('}') != std::string::npos ||
value.find('|') != std::string::npos ||
value.find('>') != std::string::npos ||
value.find('\"') != std::string::npos ||
value.find('\'') != std::string::npos ||
value.find('%') != std::string::npos || // % is YAML directive indicator
value[0] == ' ' || value[0] == '-' || value[0] == '?' ||
value[0] == '@' || value[0] == '`') {
needs_quoting = true;
}
}

if (needs_quoting) {
// Use single quotes, escape single quotes inside by doubling them
out_ << " '" << std::regex_replace(value, std::regex("'"), "''") << "'" << std::endl;
} else {
out_ << " " << value << std::endl;
}
}
}

void Value(int value, const std::string &comment = "") {
out_ << " " << value;
if (!comment.empty()) {
out_ << " # " << comment;
}
out_ << std::endl;
}

void Value(long value, const std::string &comment = "") {
out_ << " " << value;
if (!comment.empty()) {
out_ << " # " << comment;
}
out_ << std::endl;
}

void BeginSequence() {
out_ << std::endl;
}

void SequenceItem(const std::string &value, const std::string &comment = "") {
out_ << GetIndent() << "- " << value;
if (!comment.empty()) {
out_ << " # " << comment;
}
out_ << std::endl;
}

void SequenceItem(int value, const std::string &comment = "") {
out_ << GetIndent() << "- " << value;
if (!comment.empty()) {
out_ << " # " << comment;
}
out_ << std::endl;
}

void IncreaseIndent() {
indent_ += 2;
}

void DecreaseIndent() {
indent_ -= 2;
}

void Comment(const std::string &text) {
out_ << GetIndent() << "# " << text << std::endl;
}

void EmptyLine() {
out_ << std::endl;
}

// Begin a nested block (for nested maps): outputs newline after key's colon, increases indent
void BeginBlock() {
out_ << std::endl;
indent_ += 2;
}

// End a nested block: decreases indent
void EndBlock() {
indent_ -= 2;
}
};

// ============================================================================
// Helper functions for YAML comments
// ============================================================================
Expand Down
93 changes: 93 additions & 0 deletions tests/koi8r.yaml.emitter.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Regression tests for Koi8rYamlEmitter -- see issue #3273.
//
// Before the fix, values whose first character was a YAML indicator (&, *, !, ,)
// were emitted as bare plain scalars. yaml-cpp then re-parsed them as anchors,
// aliases or tags, breaking save -> reload round-trip. MUD color codes like
// "&Yfoo &Wbar&n" in object/mob names emitted unquoted yielded
// "cannot assign multiple anchors to the same node".

#ifdef HAVE_YAML

#include "engine/db/koi8r_yaml_emitter.h"

#include <gtest/gtest.h>
#include <yaml-cpp/yaml.h>
#include <sstream>
#include <string>

namespace
{

// Emit `value` as a single mapping entry "v: <value>" via the emitter, then
// parse it back with yaml-cpp and return the scalar string.
std::string RoundTrip(const std::string &value)
{
std::ostringstream out;
Koi8rYamlEmitter yaml(out);
yaml.Key("v");
yaml.Value(value);
YAML::Node node = YAML::Load(out.str());
return node["v"].as<std::string>();
}

} // namespace

TEST(Koi8rYamlEmitter, RoundTripPlainAscii)
{
EXPECT_EQ(RoundTrip("hello"), "hello");
}

TEST(Koi8rYamlEmitter, RoundTripEmpty)
{
EXPECT_EQ(RoundTrip(""), "");
}

// Exact scenario from issue #3273: multiple "&X..." color tokens. yaml-cpp used
// to interpret each "&" as an anchor declaration and fail with "cannot assign
// multiple anchors to the same node".
TEST(Koi8rYamlEmitter, ColorCodesAtStartRoundTrip)
{
const std::string color_name = "&Yfoo &Wbar&n";
EXPECT_EQ(RoundTrip(color_name), color_name);
}

// Single leading "&" also used to break (parsed as anchor, value becomes null).
TEST(Koi8rYamlEmitter, SingleLeadingAmpersandRoundTrip)
{
EXPECT_EQ(RoundTrip("&Y"), "&Y");
}

// "*" at start of a plain scalar is an alias indicator.
TEST(Koi8rYamlEmitter, LeadingAsteriskRoundTrip)
{
EXPECT_EQ(RoundTrip("*ref"), "*ref");
}

// "!" at start of a plain scalar is a tag indicator.
TEST(Koi8rYamlEmitter, LeadingExclamationRoundTrip)
{
EXPECT_EQ(RoundTrip("!important"), "!important");
}

// "," at start of a plain scalar is a flow indicator.
TEST(Koi8rYamlEmitter, LeadingCommaRoundTrip)
{
EXPECT_EQ(RoundTrip(",comma"), ",comma");
}

// Pre-existing behaviour: strings containing single quotes are escaped.
TEST(Koi8rYamlEmitter, SingleQuoteEscaped)
{
const std::string value = "it's a quote";
EXPECT_EQ(RoundTrip(value), value);
}

// Pre-existing behaviour: colons need quoting (otherwise "a: b" parses as map).
TEST(Koi8rYamlEmitter, ColonInValueRoundTrip)
{
EXPECT_EQ(RoundTrip("foo: bar"), "foo: bar");
}

#endif // HAVE_YAML

// vim: ts=4 sw=4 tw=0 noet syntax=cpp :
1 change: 1 addition & 0 deletions tests/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ test_sources = files(
'compact.trie.cpp',
'compact.trie.iterators.cpp',
'yaml.save.encoding.cpp',
'koi8r.yaml.emitter.cpp',
'compact.trie.prefixes.cpp',
'thread_pool.cpp',
'world_data_source_manager.cpp',
Expand Down
Loading