diff --git a/include/xtrpg/xml/node/IAttributes.hpp b/include/xtrpg/xml/node/IAttributes.hpp new file mode 100644 index 0000000..907e1e9 --- /dev/null +++ b/include/xtrpg/xml/node/IAttributes.hpp @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "xtrpg/xml/node/XmlCharacter.hpp" +#include "xtrpg/xml/node/XmlName.hpp" + +namespace xtrpg::xml::node { + +/** + * Provides storage, lookup, mutation, iteration, and serialization for XML + * attributes. + */ +class IAttributes { +public: + /** + * Virtual destructor. + */ + virtual ~IAttributes() = default; + + /** + * Sets an attribute key/value pair. + */ + void setAttribute(std::string_view key, std::string_view value) { + if (!isValidXmlName(key)) { + throw std::invalid_argument("Invalid XML attribute name"); + } + if (!isValidXmlCharacterData(value)) { + throw std::invalid_argument("Invalid character in XML attribute value"); + } + this->_attributes[std::string(key)] = std::string(value); + } + + /** + * Returns a copy of the value associated with the given attribute key. + */ + std::optional getAttribute(const std::string &key) const { + auto it = this->_attributes.find(key); + if (it != this->_attributes.end()) { + return it->second; + } + return std::nullopt; + } + + /** + * Removes a given attribute if it exists, returning the value that was + * removed. + */ + std::optional removeAttribute(const std::string &key) { + auto node = this->_attributes.extract(key); + if (node.empty()) { + return std::nullopt; + } + return std::move(node.mapped()); + } + + /** + * Executes a callback function for each key/value attribute pair. + * + * Callback signature: void(std::string_view key, std::string_view value) + */ + template void forEachAttribute(Func &&callback) const { + for (const auto &[key, value] : this->_attributes) { + callback(std::string_view{key}, std::string_view{value}); + } + } + + /** Serializes attributes into an XML formatted string in key order. */ + void serialize(std::ostream &os) const { + for (const auto &[attr, val] : this->_attributes) { + os << " " << attr << "=\""; + // Escape attribute values + for (char c : val) { + if (c == '"') + os << """; + else if (c == '&') + os << "&"; + else if (c == '<') + os << "<"; + else if (c == '\t') + os << " "; + else if (c == '\n') + os << " "; + else if (c == '\r') + os << " "; + else + os << c; + } + os << "\""; + } + } + +private: + std::map _attributes; +}; + +} // namespace xtrpg::xml::node \ No newline at end of file diff --git a/include/xtrpg/xml/node/INode.hpp b/include/xtrpg/xml/node/INode.hpp new file mode 100644 index 0000000..6016f69 --- /dev/null +++ b/include/xtrpg/xml/node/INode.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "xtrpg/xml/node/NodeType.hpp" +#include + +namespace xtrpg::xml::node { + +/** + * Defines the common interface and node type metadata for XML nodes. + */ +class INode { +public: + /** + * Constructor that takes in a node type. + */ + explicit INode(NodeType type) : _type(type) {} + + /** + * Virtual destructor. + */ + virtual ~INode() = default; + + /** + * Virtual method to serialize the node into the provided output stream. + */ + virtual void serialize(std::ostream &os) const = 0; + + /** + * Returns the type of this node. + */ + NodeType getNodeType() const { return this->_type; }; + + bool isType(NodeType type) const { return this->_type == type; } + +private: + NodeType _type; +}; + +/** + * Stream operator overload for easy serialization + */ +inline std::ostream &operator<<(std::ostream &os, const INode &node) { + node.serialize(os); + return os; +} + +} // namespace xtrpg::xml \ No newline at end of file diff --git a/include/xtrpg/xml/node/ITagname.hpp b/include/xtrpg/xml/node/ITagname.hpp new file mode 100644 index 0000000..bce4fe5 --- /dev/null +++ b/include/xtrpg/xml/node/ITagname.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include + +#include "xtrpg/xml/node/XmlName.hpp" + +namespace xtrpg::xml::node { + +/** + * Stores and exposes the canonical name associated with an XML tag. + */ +class ITagname { +public: + /** + * Constructor that takes in the canonical name for the tag. + */ + explicit ITagname(std::string tagname) : _tagname(std::move(tagname)) { + if (!isValidXmlName(this->_tagname)) { + throw std::invalid_argument("Invalid XML tag name"); + } + } + + /** + * Virtual destructor. + */ + virtual ~ITagname() = default; + + std::string_view getTagname() const { return this->_tagname; } + +private: + std::string _tagname; +}; + +} // namespace xtrpg::xml \ No newline at end of file diff --git a/include/xtrpg/xml/node/NodeType.hpp b/include/xtrpg/xml/node/NodeType.hpp new file mode 100644 index 0000000..00755c7 --- /dev/null +++ b/include/xtrpg/xml/node/NodeType.hpp @@ -0,0 +1,10 @@ +#pragma once + +namespace xtrpg::xml::node { + +/** + * Identifies the supported XML node categories. + */ +enum class NodeType { TAG, DECLARATION, TEXT_CONTENT }; + +} // namespace xtrpg::xml::node \ No newline at end of file diff --git a/include/xtrpg/xml/node/XmlCharacter.hpp b/include/xtrpg/xml/node/XmlCharacter.hpp new file mode 100644 index 0000000..eeeecc9 --- /dev/null +++ b/include/xtrpg/xml/node/XmlCharacter.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace xtrpg::xml::node { + +/** + * Returns whether a string contains only characters permitted in XML 1.0 + * character data and attribute values. + */ +inline bool isValidXmlCharacterData(std::string_view value) { + for (const unsigned char character : value) { + if (character < 0x20 && character != '\t' && character != '\n' && + character != '\r') { + return false; + } + } + return true; +} + +} // namespace xtrpg::xml::node diff --git a/include/xtrpg/xml/node/XmlDeclarationTag.hpp b/include/xtrpg/xml/node/XmlDeclarationTag.hpp new file mode 100644 index 0000000..ca8f4d0 --- /dev/null +++ b/include/xtrpg/xml/node/XmlDeclarationTag.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include "xtrpg/xml/node/IAttributes.hpp" +#include "xtrpg/xml/node/INode.hpp" +#include "xtrpg/xml/node/ITagname.hpp" +#include "xtrpg/xml/node/NodeType.hpp" +#include +#include +#include + +namespace xtrpg::xml::node { + +/** + * Represents an XML declaration node with a tag name and attributes. + */ +class XmlDeclarationTag : public INode, public ITagname, public IAttributes { +public: + /** + * Constructs an XML declaration. + */ + explicit XmlDeclarationTag(std::string tagname) + : INode(NodeType::DECLARATION), ITagname(std::move(tagname)), + IAttributes() { + if (this->getTagname() != "xml") { + throw std::invalid_argument("XML declaration name must be 'xml'"); + } + } + + /** + * Serializes the node into an XML formatted string. + */ + void serialize(std::ostream &os) const override { + const auto version = this->getAttribute("version"); + if (!version || (*version != "1.0" && *version != "1.1")) { + throw std::invalid_argument( + "XML declaration requires version 1.0 or 1.1"); + } + + bool hasInvalidAttribute = false; + this->forEachAttribute([&](std::string_view key, std::string_view) { + if (key != "version" && key != "encoding" && key != "standalone") { + hasInvalidAttribute = true; + } + }); + if (hasInvalidAttribute) { + throw std::invalid_argument("Invalid XML declaration attribute"); + } + + if (const auto encoding = this->getAttribute("encoding")) { + if (!isValidEncodingName(*encoding)) { + throw std::invalid_argument("Invalid XML declaration encoding"); + } + } + if (const auto standalone = this->getAttribute("standalone")) { + if (*standalone != "yes" && *standalone != "no") { + throw std::invalid_argument( + "XML declaration standalone must be 'yes' or 'no'"); + } + } + + os << "getAttribute("encoding")) { + os << " encoding=\"" << *encoding << "\""; + } + if (const auto standalone = this->getAttribute("standalone")) { + os << " standalone=\"" << *standalone << "\""; + } + os << "?>"; + } + +private: + static bool isValidEncodingName(std::string_view encoding) { + if (encoding.empty()) { + return false; + } + for (const unsigned char character : encoding) { + const bool isLetter = (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z'); + const bool isAllowed = + isLetter || (character >= '0' && character <= '9') || + character == '.' || character == '_' || character == '-'; + if (!isAllowed) { + return false; + } + } + const unsigned char first = static_cast(encoding.front()); + return (first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z'); + } +}; + +} // namespace xtrpg::xml::node \ No newline at end of file diff --git a/include/xtrpg/xml/node/XmlName.hpp b/include/xtrpg/xml/node/XmlName.hpp new file mode 100644 index 0000000..9f35867 --- /dev/null +++ b/include/xtrpg/xml/node/XmlName.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +namespace xtrpg::xml::node { + +/** + * Returns whether a string is a valid XML name using the ASCII XML Name + * character set. + */ +inline bool isValidXmlName(std::string_view name) { + if (name.empty()) { + return false; + } + + const auto isNameStart = [](unsigned char character) { + return (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || character == ':' || + character == '_'; + }; + const auto isNameCharacter = [&](unsigned char character) { + return isNameStart(character) || (character >= '0' && character <= '9') || + character == '.' || character == '-'; + }; + + if (!isNameStart(static_cast(name.front()))) { + return false; + } + + for (const unsigned char character : name.substr(1)) { + if (!isNameCharacter(character)) { + return false; + } + } + return true; +} + +} // namespace xtrpg::xml::node diff --git a/include/xtrpg/xml/node/XmlTagNode.hpp b/include/xtrpg/xml/node/XmlTagNode.hpp new file mode 100644 index 0000000..6d50a96 --- /dev/null +++ b/include/xtrpg/xml/node/XmlTagNode.hpp @@ -0,0 +1,130 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "xtrpg/xml/node/IAttributes.hpp" +#include "xtrpg/xml/node/INode.hpp" +#include "xtrpg/xml/node/ITagname.hpp" +#include "xtrpg/xml/node/NodeType.hpp" +#include "xtrpg/xml/node/XmlTextContent.hpp" + +namespace xtrpg::xml::node { + +/** + * Represents an XML element with a tag name, attributes, and child nodes. + */ +class XmlTagNode : public INode, public ITagname, public IAttributes { +public: + /** + * Inline constructor that accepts a tag name. + */ + explicit XmlTagNode(std::string name) + : INode(NodeType::TAG), ITagname(name), IAttributes() {} + + /** + * Returns a reference to the name of the tag. + */ + const std::string_view name() const { return this->getTagname(); } + + /** + * Appends a given child node. + * + * @throws std::invalid_argument if the child already contains this node. + */ + void append(std::shared_ptr child) { + if (child) { + if (containsNode(child.get(), this)) { + throw std::invalid_argument("Cannot create a cycle in XML nodes"); + } + this->_children.push_back(std::move(child)); + } + } + + /** + * Returns a vector of child nodes. + */ + const std::vector> &children() const { + return this->_children; + } + + /** + * Serializes the node into an XML formatted string. + */ + void serialize(std::ostream &os) const override { + os << "<" << this->getTagname(); + + IAttributes::serialize(os); + + if (this->_children.empty()) { + os << "/>"; + return; + } + + os << ">"; + for (const auto &child : this->_children) { + child->serialize(os); + } + os << "getTagname() << ">"; + } + +private: + /** + * Checks whether target is reachable through an XML tag node subtree. + */ + static bool containsNode(const INode *root, const INode *target) { + std::vector pending{root}; + std::unordered_set visited; + + while (!pending.empty()) { + const INode *current = pending.back(); + pending.pop_back(); + + if (!current || !visited.insert(current).second) { + continue; + } + if (current == target) { + return true; + } + + const auto *tag = dynamic_cast(current); + if (!tag) { + continue; + } + for (const auto &child : tag->_children) { + pending.push_back(child.get()); + } + } + return false; + } + + std::vector> _children; +}; + +/** + * Stream operator overload for easy serialization + */ +inline XmlTagNode &operator<<(XmlTagNode &node, std::shared_ptr child) { + node.append(std::move(child)); + return node; +} + +/** + * Stream operator overload for easy serialization, appends a new Text Node + * child containing the provided text. + */ +inline XmlTagNode &operator<<(XmlTagNode &node, const std::string &withText) { + auto textNode = std::make_shared(withText); + node.append(textNode); + return node; +} + +} // namespace xtrpg::xml::node \ No newline at end of file diff --git a/include/xtrpg/xml/node/XmlTextContent.hpp b/include/xtrpg/xml/node/XmlTextContent.hpp new file mode 100644 index 0000000..d5dc6dc --- /dev/null +++ b/include/xtrpg/xml/node/XmlTextContent.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include "xtrpg/xml/node/INode.hpp" +#include "xtrpg/xml/node/NodeType.hpp" +#include "xtrpg/xml/node/XmlCharacter.hpp" +#include +#include +#include +#include + +namespace xtrpg::xml::node { + +/** + * Represents text content within an XML document and escapes it on output. + */ +class XmlTextContent : public INode { +public: + /** + * Default constructor that generate a blank text node. + */ + XmlTextContent() : INode(NodeType::TEXT_CONTENT) {} + + /** + * Constructor that takes in a copy of the text content to store within this + * node. + */ + explicit XmlTextContent(std::string content) + : INode(NodeType::TEXT_CONTENT), _content(std::move(content)) { + if (!isValidXmlCharacterData(this->_content)) { + throw std::invalid_argument("Invalid character in XML text content"); + } + } + + /** + * Returns a reference to the text content stored on this node. + */ + const std::string &content() const { return this->_content; } + + /** + * Sets the text content stored on this node, overriding any previous content. + */ + void content(std::string withContent) { + if (!isValidXmlCharacterData(withContent)) { + throw std::invalid_argument("Invalid character in XML text content"); + } + this->_content = std::move(withContent); + } + + /** + * Appends the provided string to the end of the stored text content. + */ + void append(const std::string &withText) { + if (!isValidXmlCharacterData(withText)) { + throw std::invalid_argument("Invalid character in XML text content"); + } + this->_content += withText; + } + + /** + * Serializes the text content into the provided output stream, automatically + * escaping and special XML entities. + */ + void serialize(std::ostream &os) const override { + for (char c : this->_content) { + switch (c) { + case '<': + os << "<"; + break; + case '>': + os << ">"; + break; + case '&': + os << "&"; + break; + default: + os << c; + break; + } + } + } + +private: + std::string _content; +}; + +/** + * Stream insertion overload. (node << text). + */ +inline XmlTextContent &operator<<(XmlTextContent &node, + const std::string &withText) { + node.append(withText); + return node; +} + +/** + * Stream extraction overload: reads one line and appends the characters read + * to the content of the node without adding a delimiter. + */ +inline std::istream &operator>>(std::istream &is, XmlTextContent &node) { + std::string temp; + if (std::getline(is, temp)) { + node.append(temp); + } + return is; +} + +} // namespace xtrpg::xml::node \ No newline at end of file