From 359f4f4bb681484fd5dbc6d04df0be21eef8dae7 Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Thu, 18 Jun 2026 10:22:19 -0400 Subject: [PATCH 1/2] schemagen: deduplicate enum constants that collide after sanitization Distinct JSON enum values can sanitize to the same C++ identifier (e.g. "foo-bar" and "foo_bar" both become `foo_bar`), producing an invalid `enum class` with duplicate constants. Add `unique_enum_identifiers()` which appends a numeric suffix to later collisions while preserving enum declaration order, so the index-to-JSON-value mapping used by the generated parser stays intact. Also add contrib/schemagen/test_schemagen.py and wire the schemagen Python tests plus the existing example.schema.json/test_gen.cpp example into ctest via CMakeLists.txt. Closes #4 --- CMakeLists.txt | 32 ++++++++++++++ contrib/schemagen/test_schemagen.py | 53 +++++++++++++++++++++++ contrib/schemagen/weaseljson_schemagen.py | 30 ++++++++++++- 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 contrib/schemagen/test_schemagen.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 41acdeb..7d364b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -170,6 +170,38 @@ if(Python3_Interpreter_FOUND) ${CMAKE_CURRENT_SOURCE_DIR}/test_python_bindings.py) endif() +# schemagen tests +add_test( + NAME schemagen_python_tests + COMMAND python3 contrib/schemagen/test_schemagen.py + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + +set(SCHEMAGEN_DIR ${CMAKE_SOURCE_DIR}/contrib/schemagen) +set(SCHEMAGEN_SCRIPT ${SCHEMAGEN_DIR}/weaseljson_schemagen.py) +set(EXAMPLE_SCHEMA ${SCHEMAGEN_DIR}/example.schema.json) +set(GEN_H ${CMAKE_BINARY_DIR}/gen.h) + +add_custom_command( + OUTPUT ${GEN_H} + COMMAND python3 ${SCHEMAGEN_SCRIPT} ${EXAMPLE_SCHEMA} -o ${GEN_H} --namespace + weasel_schema + DEPENDS ${SCHEMAGEN_SCRIPT} ${EXAMPLE_SCHEMA} + COMMENT "Generating gen.h from example.schema.json") + +add_custom_target(schemagen_gen_h DEPENDS ${GEN_H}) + +add_executable(schemagen_example ${SCHEMAGEN_DIR}/test_gen.cpp) +target_include_directories(schemagen_example PRIVATE include + ${CMAKE_BINARY_DIR}) +target_link_libraries(schemagen_example PRIVATE ${PROJECT_NAME}) +target_compile_options(schemagen_example PRIVATE -Wno-switch-enum) +add_dependencies(schemagen_example schemagen_gen_h) + +add_test( + NAME schemagen_example + COMMAND schemagen_example + WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) + include(CMakePushCheckState) include(CheckCXXCompilerFlag) cmake_push_check_state() diff --git a/contrib/schemagen/test_schemagen.py b/contrib/schemagen/test_schemagen.py new file mode 100644 index 0000000..afd71fe --- /dev/null +++ b/contrib/schemagen/test_schemagen.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Tests for weaseljson_schemagen.py.""" + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +SCRIPT = os.path.join(os.path.dirname(__file__), "weaseljson_schemagen.py") + + +class SchemagenEnumTest(unittest.TestCase): + def run_schemagen(self, schema, args=None): + """Run schemagen on a schema dict. Returns (returncode, stdout, stderr).""" + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp: + json.dump(schema, fp) + schema_path = fp.name + try: + cmd = [sys.executable, SCRIPT, schema_path] + if args: + cmd.extend(args) + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + return result.returncode, result.stdout, result.stderr + finally: + os.unlink(schema_path) + + def test_colliding_enum_values_deduplicate(self): + schema = { + "type": "object", + "properties": {"role": {"enum": ["foo-bar", "foo_bar"]}}, + } + rc, stdout, stderr = self.run_schemagen(schema) + self.assertEqual(rc, 0, msg=stderr) + self.assertIn("enum class Role : int { foo_bar, foo_bar_1 };", stdout) + self.assertIn( + 'static constexpr const char *Role_names[] = { "foo-bar", "foo_bar" };', + stdout, + ) + + def test_distinct_enum_values_generate(self): + schema = { + "type": "object", + "properties": {"role": {"enum": ["admin", "user", "guest"]}}, + } + rc, stdout, stderr = self.run_schemagen(schema) + self.assertEqual(rc, 0, msg=stderr) + self.assertIn("enum class Role : int { admin, user, guest };", stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/contrib/schemagen/weaseljson_schemagen.py b/contrib/schemagen/weaseljson_schemagen.py index e7a1a6c..792cba5 100644 --- a/contrib/schemagen/weaseljson_schemagen.py +++ b/contrib/schemagen/weaseljson_schemagen.py @@ -86,6 +86,34 @@ def sanitize(name, fallback="x"): return s +def unique_enum_identifiers(values): + """Return a list of sanitized C++ identifiers, one per input value. + + Distinct JSON enum values may sanitize to the same C++ token (e.g. + "foo-bar" and "foo_bar" both become "foo_bar"). This helper appends a + numeric suffix to later collisions so the generated `enum class` stays + valid while preserving the original order and therefore the index-to-value + mapping used at parse time. + """ + used = set() + out = [] + for v in values: + base = sanitize(v) + if base not in used: + used.add(base) + out.append(base) + continue + n = 1 + while True: + candidate = f"{base}_{n}" + if candidate not in used: + used.add(candidate) + out.append(candidate) + break + n += 1 + return out + + def camel(name): parts = [p for p in name.replace("-", " ").replace("_", " ").split(" ") if p] if not parts: @@ -508,7 +536,7 @@ namespace {ns} {{""" return "" out = [] for e in self.b.enums.values(): - vals = ", ".join(sanitize(v) for v in e.values) + vals = ", ".join(unique_enum_identifiers(e.values)) out.append(f"enum class {e.name} : int {{ {vals} }};") return "\n".join(out) + "\n" From 859fa41ecbed8cd3020dfd734d8ef425d04e369f Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Thu, 18 Jun 2026 15:26:55 -0400 Subject: [PATCH 2/2] schemagen: move test configuration into contrib/schemagen/CMakeLists.txt Addresses review feedback: keep the schemagen-specific CMake rules close to the tool instead of inline in the top-level CMakeLists.txt. The subdirectory file is added from the root and guarded by the same Python3 availability check that was already in use. --- CMakeLists.txt | 32 +---------------------------- contrib/schemagen/CMakeLists.txt | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 contrib/schemagen/CMakeLists.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d364b9..eafe77b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -170,37 +170,7 @@ if(Python3_Interpreter_FOUND) ${CMAKE_CURRENT_SOURCE_DIR}/test_python_bindings.py) endif() -# schemagen tests -add_test( - NAME schemagen_python_tests - COMMAND python3 contrib/schemagen/test_schemagen.py - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) - -set(SCHEMAGEN_DIR ${CMAKE_SOURCE_DIR}/contrib/schemagen) -set(SCHEMAGEN_SCRIPT ${SCHEMAGEN_DIR}/weaseljson_schemagen.py) -set(EXAMPLE_SCHEMA ${SCHEMAGEN_DIR}/example.schema.json) -set(GEN_H ${CMAKE_BINARY_DIR}/gen.h) - -add_custom_command( - OUTPUT ${GEN_H} - COMMAND python3 ${SCHEMAGEN_SCRIPT} ${EXAMPLE_SCHEMA} -o ${GEN_H} --namespace - weasel_schema - DEPENDS ${SCHEMAGEN_SCRIPT} ${EXAMPLE_SCHEMA} - COMMENT "Generating gen.h from example.schema.json") - -add_custom_target(schemagen_gen_h DEPENDS ${GEN_H}) - -add_executable(schemagen_example ${SCHEMAGEN_DIR}/test_gen.cpp) -target_include_directories(schemagen_example PRIVATE include - ${CMAKE_BINARY_DIR}) -target_link_libraries(schemagen_example PRIVATE ${PROJECT_NAME}) -target_compile_options(schemagen_example PRIVATE -Wno-switch-enum) -add_dependencies(schemagen_example schemagen_gen_h) - -add_test( - NAME schemagen_example - COMMAND schemagen_example - WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +add_subdirectory(contrib/schemagen) include(CMakePushCheckState) include(CheckCXXCompilerFlag) diff --git a/contrib/schemagen/CMakeLists.txt b/contrib/schemagen/CMakeLists.txt new file mode 100644 index 0000000..7cae8eb --- /dev/null +++ b/contrib/schemagen/CMakeLists.txt @@ -0,0 +1,35 @@ +# Tests for contrib/schemagen + +if(NOT Python3_Interpreter_FOUND) + return() +endif() + +add_test( + NAME schemagen_python_tests + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_schemagen.py + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + +set(SCHEMAGEN_SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/weaseljson_schemagen.py) +set(EXAMPLE_SCHEMA ${CMAKE_CURRENT_SOURCE_DIR}/example.schema.json) +set(GEN_H ${CMAKE_CURRENT_BINARY_DIR}/gen.h) + +add_custom_command( + OUTPUT ${GEN_H} + COMMAND ${Python3_EXECUTABLE} ${SCHEMAGEN_SCRIPT} ${EXAMPLE_SCHEMA} -o + ${GEN_H} --namespace weasel_schema + DEPENDS ${SCHEMAGEN_SCRIPT} ${EXAMPLE_SCHEMA} + COMMENT "Generating gen.h from example.schema.json") + +add_custom_target(schemagen_gen_h DEPENDS ${GEN_H}) + +add_executable(schemagen_example ${CMAKE_CURRENT_SOURCE_DIR}/test_gen.cpp) +target_include_directories(schemagen_example + PRIVATE include ${CMAKE_CURRENT_BINARY_DIR}) +target_link_libraries(schemagen_example PRIVATE ${PROJECT_NAME}) +target_compile_options(schemagen_example PRIVATE -Wno-switch-enum) +add_dependencies(schemagen_example schemagen_gen_h) + +add_test( + NAME schemagen_example + COMMAND schemagen_example + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})