diff --git a/CMakeLists.txt b/CMakeLists.txt index 416c2c332..1fef1aa3e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -210,6 +210,15 @@ if (WIN32) list(APPEND BT_SOURCE src/shared_library_WIN.cpp ) endif() +# Embed src/btcpp4_schematron.sch as a C++ string constant. +# configure_file tracks the input file: CMake reruns automatically when it changes. +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/btcpp4_schematron.sch" BTCPP_SCHEMATRON_TEMPLATE) +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/src/schematron_template.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/generated/schematron_template.gen.h" + @ONLY +) + if (BTCPP_SHARED_LIBS) set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) add_library(${BTCPP_LIBRARY} SHARED ${BT_SOURCE}) @@ -252,6 +261,7 @@ target_include_directories(${BTCPP_LIBRARY} $ PRIVATE ${BTCPP_EXTRA_INCLUDE_DIRS} + ${CMAKE_CURRENT_BINARY_DIR}/generated ) target_compile_definitions(${BTCPP_LIBRARY} PUBLIC BTCPP_LIBRARY_VERSION="${CMAKE_PROJECT_VERSION}") diff --git a/include/behaviortree_cpp/xml_parsing.h b/include/behaviortree_cpp/xml_parsing.h index d815ef85b..3342cf7a9 100644 --- a/include/behaviortree_cpp/xml_parsing.h +++ b/include/behaviortree_cpp/xml_parsing.h @@ -59,13 +59,43 @@ void VerifyXML(const std::string& xml_text, bool include_builtin = false); /** - * @brief writeTreeXSD generates an XSD for the nodes defined in the factory + * @brief writeTreeXSD generates an XSD for the nodes defined in the factory. * - * @param factory the factory with the registered types + * @param factory the factory with the registered types + * @param generic if true, oneNodeGroup uses xs:any processContents="lax" + * instead of a closed xs:choice, allowing unknown custom + * node elements to pass validation. Top-level xs:element + * declarations are also emitted so that lax processing can + * still resolve and validate the known built-in node types. + * Defaults to false (strict, closed-vocabulary schema). * - * @return string containing the XML. + * @return string containing the XSD XML. + */ +[[nodiscard]] std::string writeTreeXSD(const BehaviorTreeFactory& factory, + bool generic = false); + +/** + * @brief writeTreeSchematron generates an ISO Schematron schema for BehaviorTree.CPP XML files. + * + * XSD alone cannot express cross-reference constraints. This Schematron + * complements writeTreeXSD() with three rule patterns: + * - treeNodesModel: every custom (non-built-in) node element appearing in a + * BehaviorTree body must have a matching TreeNodesModel entry (required by + * Groot2 for port display and editing). + * - subtreeResolution: every must resolve to a + * in the same file (relaxed when is present). + * - rootStructure: main_tree_to_execute must name an existing BehaviorTree. + * + * The built-in node list is derived from factory.builtinNodes() so that the + * schema stays in sync as new built-ins are added. + * + * @param factory factory from which the built-in node names are taken; + * a default-constructed BehaviorTreeFactory suffices for most uses. + * + * @return string containing the Schematron XML (queryBinding="xslt", + * compatible with xsltproc and lxml.isoschematron). */ -[[nodiscard]] std::string writeTreeXSD(const BehaviorTreeFactory& factory); +[[nodiscard]] std::string writeTreeSchematron(const BehaviorTreeFactory& factory); /** * @brief WriteTreeToXML create a string that contains the XML that corresponds to a given tree. diff --git a/src/btcpp4_schematron.sch b/src/btcpp4_schematron.sch new file mode 100644 index 000000000..21aacc651 --- /dev/null +++ b/src/btcpp4_schematron.sch @@ -0,0 +1,79 @@ + + + BehaviorTree.CPP XML validation rules + + + + TreeNodesModel completeness + + + + + Custom node '' used in tree + '' + has no <TreeNodesModel> entry. + + + + + + + Node with ID='' used in tree + '' + has no <TreeNodesModel> entry. + + + + + + + SubTree ID cross-references + + + SubTree ID='' is not defined in this file. + (If the definition lives in an included file, add an <include>.) + + + + + + Root element consistency + + + main_tree_to_execute='' + does not match any BehaviorTree ID in this file. + + + + + diff --git a/src/schematron_template.h.in b/src/schematron_template.h.in new file mode 100644 index 000000000..972943419 --- /dev/null +++ b/src/schematron_template.h.in @@ -0,0 +1,7 @@ +// Auto-generated from src/btcpp4_schematron.sch — do not edit directly. +// To update: edit src/btcpp4_schematron.sch and re-run CMake. +namespace BT { namespace detail { +static const char* const schematron_template = +R"BTCPP4SCH(@BTCPP_SCHEMATRON_TEMPLATE@)BTCPP4SCH"; +} // namespace detail +} // namespace BT diff --git a/src/xml_parsing.cpp b/src/xml_parsing.cpp index b4a82e976..ef6b8fdbf 100644 --- a/src/xml_parsing.cpp +++ b/src/xml_parsing.cpp @@ -53,6 +53,8 @@ #include #endif +#include "schematron_template.gen.h" + #include "behaviortree_cpp/blackboard.h" #include "behaviortree_cpp/tree_node.h" #include "behaviortree_cpp/utils/demangle_util.h" @@ -1473,7 +1475,7 @@ std::string writeTreeNodesModelXML(const BehaviorTreeFactory& factory, return std::string(printer.CStr(), size_t(printer.CStrSize() - 1)); } -std::string writeTreeXSD(const BehaviorTreeFactory& factory) +std::string writeTreeXSD(const BehaviorTreeFactory& factory, bool generic) { // There are 2 forms of representation for a node: // compact: and explicit: @@ -1481,10 +1483,12 @@ std::string writeTreeXSD(const BehaviorTreeFactory& factory) // make sense with XSD since we would need to allow any attribute. // Prepare the data + const auto& builtin_set = factory.builtinNodes(); std::map ordered_models; for(const auto& [registration_id, model] : factory.manifests()) { - ordered_models.insert({ registration_id, &model }); + if(!generic || builtin_set.count(registration_id) != 0) + ordered_models.insert({ registration_id, &model }); } XMLDocument doc; @@ -1565,6 +1569,15 @@ std::string writeTreeXSD(const BehaviorTreeFactory& factory) + + + + + + + + + @@ -1598,6 +1611,7 @@ std::string writeTreeXSD(const BehaviorTreeFactory& factory) + @@ -1635,15 +1649,27 @@ std::string writeTreeXSD(const BehaviorTreeFactory& factory) XMLElement* one_node_group = doc.NewElement("xs:group"); { one_node_group->SetAttribute("name", "oneNodeGroup"); - std::ostringstream xsd; - xsd << ""; - for(const auto& [registration_id, model] : ordered_models) + if(generic) + { + // Generic: accept any element; lax processing validates known built-ins + // via the top-level xs:element declarations emitted at the end of this function. + parse_and_insert(one_node_group, "" + "" + ""); + } + else { - xsd << ""; + std::ostringstream xsd; + xsd << ""; + for(const auto& [registration_id, model] : ordered_models) + { + xsd << ""; + } + xsd << ""; + parse_and_insert(one_node_group, xsd.str().c_str()); } - xsd << ""; - parse_and_insert(one_node_group, xsd.str().c_str()); schema_element->InsertEndChild(one_node_group); } @@ -1710,8 +1736,12 @@ std::string writeTreeXSD(const BehaviorTreeFactory& factory) XMLElement* common_attr_group = doc.NewElement("xs:attributeGroup"); common_attr_group->SetAttribute("ref", "commonAttributeGroup"); type->InsertEndChild(common_attr_group); + std::map ordered_ports; for(const auto& [port_name, port_info] : model->ports) + ordered_ports.insert({ port_name, &port_info }); + for(const auto& [port_name, port_info_ptr] : ordered_ports) { + const auto& port_info = *port_info_ptr; XMLElement* attr = doc.NewElement("xs:attribute"); attr->SetAttribute("name", port_name.c_str()); const auto xsd_attribute_type = xsdAttributeType(port_info); @@ -1739,11 +1769,44 @@ std::string writeTreeXSD(const BehaviorTreeFactory& factory) schema_element->InsertEndChild(type); } + // Generic mode: emit top-level xs:element declarations so that + // processContents="lax" in oneNodeGroup can resolve and validate + // known built-in node elements by name. + if(generic) + { + for(const auto& [registration_id, model] : ordered_models) + { + XMLElement* elem = doc.NewElement("xs:element"); + elem->SetAttribute("name", registration_id.c_str()); + elem->SetAttribute("type", (registration_id + "Type").c_str()); + schema_element->InsertEndChild(elem); + } + } + XMLPrinter printer; doc.Print(&printer); return std::string(printer.CStr(), size_t(printer.CStrSize() - 1)); } +std::string writeTreeSchematron(const BehaviorTreeFactory& factory) +{ + // Build the pipe-delimited builtin node list, e.g. "|AlwaysFailure|Fallback|...|", + // then substitute the ##BUILTIN_PIPE## placeholder in the embedded template. + // The template itself lives in src/btcpp4_schematron.sch (human-readable); + // schematron_template.gen.h is generated from it by CMake. + std::ostringstream builtin_pipe_ss; + builtin_pipe_ss << "|"; + for(const auto& name : factory.builtinNodes()) + builtin_pipe_ss << name << "|"; + + std::string result = BT::detail::schematron_template; + const std::string placeholder = "##BUILTIN_PIPE##"; + const auto pos = result.find(placeholder); + if(pos != std::string::npos) + result.replace(pos, placeholder.size(), builtin_pipe_ss.str()); + return result; +} + std::string WriteTreeToXML(const Tree& tree, bool add_metadata, bool add_builtin_models) { XMLDocument doc; diff --git a/tests/gtest_factory.cpp b/tests/gtest_factory.cpp index 01718cbce..c90e22604 100644 --- a/tests/gtest_factory.cpp +++ b/tests/gtest_factory.cpp @@ -777,3 +777,112 @@ TEST(BehaviorTreeFactory, MalformedXML_UnknownNodeType) BehaviorTreeFactory factory; EXPECT_THROW(factory.createTreeFromText(xml), RuntimeError); } + +// --------------------------------------------------------------------------- +// writeTreeXSD tests +// --------------------------------------------------------------------------- + +TEST(WriteTreeXSD, WellFormedOutput) +{ + BehaviorTreeFactory factory; + auto xsd = writeTreeXSD(factory); + EXPECT_NE(xsd.find(""), std::string::npos); +} + +TEST(WriteTreeXSD, ContainsBuiltinNodes) +{ + BehaviorTreeFactory factory; + auto xsd = writeTreeXSD(factory); + EXPECT_NE(xsd.find("name=\"Sequence\""), std::string::npos); + EXPECT_NE(xsd.find("name=\"Fallback\""), std::string::npos); + EXPECT_NE(xsd.find("name=\"Inverter\""), std::string::npos); +} + +TEST(WriteTreeXSD, CustomNodeAppearsInOutput) +{ + BehaviorTreeFactory factory; + factory.registerNodeType("SaySomething"); + auto xsd = writeTreeXSD(factory); + EXPECT_NE(xsd.find("name=\"SaySomething\""), std::string::npos); + EXPECT_NE(xsd.find("name=\"message\""), std::string::npos); +} + +TEST(WriteTreeXSD, GenericModeUsesXsAny) +{ + BehaviorTreeFactory factory; + auto xsd_strict = writeTreeXSD(factory, false); + auto xsd_generic = writeTreeXSD(factory, true); + EXPECT_EQ(xsd_strict.find("processContents=\"lax\""), std::string::npos); + EXPECT_NE(xsd_generic.find("processContents=\"lax\""), std::string::npos); +} + +TEST(WriteTreeXSD, GenericModeExcludesCustomNodes) +{ + BehaviorTreeFactory factory; + factory.registerNodeType("SaySomething"); + auto xsd_strict = writeTreeXSD(factory, false); + auto xsd_generic = writeTreeXSD(factory, true); + // Custom nodes must appear in strict but not in generic (generic accepts them via xs:any) + EXPECT_NE(xsd_strict.find("SaySomething"), std::string::npos); + EXPECT_EQ(xsd_generic.find("SaySomething"), std::string::npos); +} + +TEST(WriteTreeXSD, Deterministic) +{ + BehaviorTreeFactory factory; + factory.registerNodeType("SaySomething"); + auto xsd1 = writeTreeXSD(factory); + auto xsd2 = writeTreeXSD(factory); + EXPECT_EQ(xsd1, xsd2); +} + +// --------------------------------------------------------------------------- +// writeTreeSchematron tests +// --------------------------------------------------------------------------- + +TEST(WriteTreeSchematron, WellFormedOutput) +{ + BehaviorTreeFactory factory; + auto sch = writeTreeSchematron(factory); + EXPECT_NE(sch.find(""), std::string::npos); +} + +TEST(WriteTreeSchematron, PlaceholderReplaced) +{ + BehaviorTreeFactory factory; + auto sch = writeTreeSchematron(factory); + EXPECT_EQ(sch.find("##BUILTIN_PIPE##"), std::string::npos); +} + +TEST(WriteTreeSchematron, BuiltinNodesInPipeList) +{ + BehaviorTreeFactory factory; + auto sch = writeTreeSchematron(factory); + EXPECT_NE(sch.find("|Sequence|"), std::string::npos); + EXPECT_NE(sch.find("|Fallback|"), std::string::npos); + EXPECT_NE(sch.find("|Inverter|"), std::string::npos); +} + +TEST(WriteTreeSchematron, ContainsAllPatternIds) +{ + BehaviorTreeFactory factory; + auto sch = writeTreeSchematron(factory); + EXPECT_NE(sch.find("id=\"treeNodesModel\""), std::string::npos); + EXPECT_NE(sch.find("id=\"subtreeResolution\""), std::string::npos); + EXPECT_NE(sch.find("id=\"rootStructure\""), std::string::npos); +} + +TEST(WriteTreeSchematron, CustomNodeNotInBuiltinPipe) +{ + BehaviorTreeFactory factory; + factory.registerNodeType("SaySomething"); + auto sch = writeTreeSchematron(factory); + // Custom nodes must not appear in the builtin pipe exclusion list + EXPECT_EQ(sch.find("|SaySomething|"), std::string::npos); + // Builtins must still be present + EXPECT_NE(sch.find("|Sequence|"), std::string::npos); +} diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index e5d3f4219..d822898b7 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -29,3 +29,13 @@ add_executable(bt4_nodes_model bt_nodes_model.cpp ) target_link_libraries(bt4_nodes_model ${BTCPP_LIBRARY} ) install(TARGETS bt4_nodes_model DESTINATION ${BTCPP_BIN_DESTINATION} ) + +add_executable(bt4_generate_xsd generate_xsd.cpp ) +target_link_libraries(bt4_generate_xsd ${BTCPP_LIBRARY} ) +install(TARGETS bt4_generate_xsd + DESTINATION ${BTCPP_BIN_DESTINATION} ) + +add_executable(bt4_generate_schematron generate_schematron.cpp ) +target_link_libraries(bt4_generate_schematron ${BTCPP_LIBRARY} ) +install(TARGETS bt4_generate_schematron + DESTINATION ${BTCPP_BIN_DESTINATION} ) diff --git a/tools/generate_schematron.cpp b/tools/generate_schematron.cpp new file mode 100644 index 000000000..40f61f6d9 --- /dev/null +++ b/tools/generate_schematron.cpp @@ -0,0 +1,52 @@ +#include + +#include +#include + +// Generates an ISO Schematron schema for BehaviorTree.CPP XML files and +// prints it to stdout. +// +// The Schematron complements the generic XSD (bt4_generate_xsd) with +// cross-reference rules that XSD cannot express: +// +// - treeNodesModel: every custom (non-built-in) node used in a BehaviorTree +// body must have a matching entry (required by Groot2). +// - subtreeResolution: every must resolve to a +// in the same file. +// - rootStructure: main_tree_to_execute must name an existing BehaviorTree. +// +// Usage (two-step validation): +// +// # Generate schemas +// ./bt4_generate_xsd > btcpp4.xsd +// ./bt4_generate_schematron > btcpp4.sch +// +// # Step 1 — structural validation (XSD) +// xmllint --noout --schema btcpp4.xsd myfile.xml +// +// # Step 2 — cross-reference validation (Schematron) +// # Option A: xsltproc + ISO Schematron XSLT1 skeleton +// # (download iso_schematron_skeleton_for_xslt1.xsl from +// # https://github.com/Schematron/schematron) +// xsltproc iso_schematron_skeleton_for_xslt1.xsl btcpp4.sch > btcpp4_validator.xsl +// xsltproc btcpp4_validator.xsl myfile.xml +// +// # Option B: Python + lxml (no XSLT skeleton needed) +// pip install lxml +// python3 - << 'EOF' +// from lxml import etree, isoschematron +// ns = "http://purl.oclc.org/dsdl/svrl" +// sch = isoschematron.Schematron(etree.parse("btcpp4.sch"), store_report=True) +// if not sch.validate(etree.parse("myfile.xml")): +// for fa in sch.validation_report.findall(f".//{{{ns}}}failed-assert"): +// print(f"{fa.get('location')}: {fa.findtext(f'{{{ns}}}text', '').strip()}") +// EOF +// +// # Option C: Java + SchXslt +// java -jar schxslt-cli.jar -s btcpp4.sch -i myfile.xml +int main() +{ + BT::BehaviorTreeFactory factory; + std::cout << BT::writeTreeSchematron(factory); + return 0; +} diff --git a/tools/generate_xsd.cpp b/tools/generate_xsd.cpp new file mode 100644 index 000000000..9a9ed77b0 --- /dev/null +++ b/tools/generate_xsd.cpp @@ -0,0 +1,22 @@ +#include + +#include +#include + +// Generates a generic XSD schema for BehaviorTree.CPP XML files and +// prints it to stdout. +// +// The schema covers all built-in node types with their specific attributes +// and child-count constraints. Unknown custom node elements are accepted via +// xs:any processContents="lax", and top-level xs:element declarations allow +// lax processing to still validate built-in node elements by name. +// +// Usage: +// ./bt4_generate_xsd > btcpp4.xsd +// xmllint --noout --schema btcpp4.xsd myfile.xml +int main() +{ + BT::BehaviorTreeFactory factory; + std::cout << BT::writeTreeXSD(factory, /*generic=*/true); + return 0; +} diff --git a/tools/generated/bt4.sch b/tools/generated/bt4.sch new file mode 100644 index 000000000..ff58d4749 --- /dev/null +++ b/tools/generated/bt4.sch @@ -0,0 +1,79 @@ + + + BehaviorTree.CPP XML validation rules + + + + TreeNodesModel completeness + + + + + Custom node '' used in tree + '' + has no <TreeNodesModel> entry. + + + + + + + Node with ID='' used in tree + '' + has no <TreeNodesModel> entry. + + + + + + + SubTree ID cross-references + + + SubTree ID='' is not defined in this file. + (If the definition lives in an included file, add an <include>.) + + + + + + Root element consistency + + + main_tree_to_execute='' + does not match any BehaviorTree ID in this file. + + + + + diff --git a/tools/generated/bt4.xsd b/tools/generated/bt4.xsd new file mode 100644 index 000000000..6daf42649 --- /dev/null +++ b/tools/generated/bt4.xsd @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/validate_xml.py b/tools/validate_xml.py new file mode 100755 index 000000000..5df3bb81f --- /dev/null +++ b/tools/validate_xml.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Validate a BehaviorTree.CPP XML file against XSD and/or Schematron schemas. + +Exit codes: + 0 all enabled validations passed + 1 one or more validation errors + 2 usage error or file not found +""" + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +try: + from lxml import etree, isoschematron + _LXML = True +except ImportError: + _LXML = False + +_SCRIPT_DIR = Path(__file__).resolve().parent +_DEFAULT_XSD = _SCRIPT_DIR / "generated" / "bt4.xsd" +_DEFAULT_SCH = _SCRIPT_DIR / "generated" / "bt4.sch" + + +def validate_xsd(xml_file: str, schema_file: str, force_lxml: bool = False) -> bool: + if not force_lxml and shutil.which("xmllint"): + result = subprocess.run( + ["xmllint", "--noout", "--schema", schema_file, xml_file], + capture_output=True, text=True, + ) + if result.returncode != 0: + sys.stderr.write(result.stderr) + return False + return True + + if not _LXML: + print("error: neither xmllint nor lxml is available for XSD validation", file=sys.stderr) + sys.exit(2) + + schema = etree.XMLSchema(etree.parse(schema_file)) + doc = etree.parse(xml_file) + if not schema.validate(doc): + for err in schema.error_log: + print(f"{xml_file}:{err.line}: {err.message}", file=sys.stderr) + return False + return True + + +def validate_schematron(xml_file: str, sch_file: str) -> bool: + if not _LXML: + print("error: lxml is required for Schematron validation (pip install lxml)", file=sys.stderr) + sys.exit(2) + + sch = isoschematron.Schematron(etree.parse(sch_file), store_report=True) + ok = sch.validate(etree.parse(xml_file)) + if not ok: + ns = "http://purl.oclc.org/dsdl/svrl" + for fa in sch.validation_report.findall(f".//{{{ns}}}failed-assert"): + loc = fa.get("location") + text = fa.findtext(f"{{{ns}}}text", "").strip() + print(f"{xml_file}: {loc}: {text}", file=sys.stderr) + return ok + + +def main(): + parser = argparse.ArgumentParser( + description="Validate a BehaviorTree.CPP XML file against XSD and Schematron schemas.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +examples: + %(prog)s myfile.xml + %(prog)s --schema custom.xsd --schematron custom.sch myfile.xml + %(prog)s --no-xsd myfile.xml + %(prog)s --no-sch myfile.xml +""", + ) + parser.add_argument("xml_file", help="BehaviorTree XML file to validate") + parser.add_argument( + "-s", "--schema", + default=str(_DEFAULT_XSD), + metavar="FILE", + help=f"XSD schema (default: {_DEFAULT_XSD})", + ) + parser.add_argument( + "-t", "--schematron", + default=str(_DEFAULT_SCH), + metavar="FILE", + help=f"Schematron schema (default: {_DEFAULT_SCH})", + ) + parser.add_argument("--no-xsd", action="store_true", help="skip XSD validation") + parser.add_argument("--no-sch", action="store_true", help="skip Schematron validation") + parser.add_argument("--lxml", action="store_true", help="force use of lxml for XSD validation") + + args = parser.parse_args() + + if not Path(args.xml_file).exists(): + parser.error(f"file not found: {args.xml_file}") + + passed = True + + if not args.no_xsd: + if not Path(args.schema).exists(): + parser.error(f"XSD schema not found: {args.schema}") + if not validate_xsd(args.xml_file, args.schema, force_lxml=args.lxml): + passed = False + + if not args.no_sch: + if not Path(args.schematron).exists(): + parser.error(f"Schematron schema not found: {args.schematron}") + if not validate_schematron(args.xml_file, args.schematron): + passed = False + + if passed: + print(f"{args.xml_file} validates OK") + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main()