From 800e4544fca1d9f2978a0059db240c54329a130d Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Thu, 18 Jun 2026 10:22:19 -0400 Subject: [PATCH] schemagen: reject enum values that collide after C++ sanitization When two distinct JSON enum values sanitized to the same C++ identifier, weaseljson_schemagen.py produced an enum class with duplicate constants, generating invalid C++. Detect this at generation time and emit a clear GenError instead. Also add contrib/schemagen/test_schemagen.py with unit tests for the collision case and the normal generation path, and wire both the Python tests and the existing example.schema.json/test_gen.cpp example into ctest via CMakeLists.txt. Closes #4 --- CMakeLists.txt | 32 ++++++++++++++ contrib/schemagen/test_schemagen.py | 51 +++++++++++++++++++++++ contrib/schemagen/weaseljson_schemagen.py | 9 ++++ 3 files changed, 92 insertions(+) create mode 100644 contrib/schemagen/test_schemagen.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 64a6ccd..5f04b6b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -163,6 +163,38 @@ target_link_libraries(mytest PRIVATE ${PROJECT_NAME} doctest nanobench simdjson) target_compile_options(mytest PRIVATE ${TEST_FLAGS}) doctest_discover_tests(mytest WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) +# 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..c5dba60 --- /dev/null +++ b/contrib/schemagen/test_schemagen.py @@ -0,0 +1,51 @@ +#!/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_raise_error(self): + schema = { + "type": "object", + "properties": {"role": {"enum": ["foo-bar", "foo_bar"]}}, + } + rc, stdout, stderr = self.run_schemagen(schema) + self.assertNotEqual(rc, 0) + self.assertIn("foo-bar", stderr) + self.assertIn("foo_bar", stderr) + self.assertIn("C++ identifier", stderr) + + 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..289a254 100644 --- a/contrib/schemagen/weaseljson_schemagen.py +++ b/contrib/schemagen/weaseljson_schemagen.py @@ -508,6 +508,15 @@ namespace {ns} {{""" return "" out = [] for e in self.b.enums.values(): + seen = {} + for v in e.values: + ident = sanitize(v) + if ident in seen: + raise GenError( + f"enum {e.name!r} values {seen[ident]!r} and {v!r} " + f"both sanitize to C++ identifier {ident!r}" + ) + seen[ident] = v vals = ", ".join(sanitize(v) for v in e.values) out.append(f"enum class {e.name} : int {{ {vals} }};") return "\n".join(out) + "\n"