diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 3b2e05d..d29d7cc 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -1,5 +1,3 @@
-# test suite
-
 include_directories(
     ${CMAKE_CURRENT_SOURCE_DIR}
     ${CMAKE_CURRENT_BINARY_DIR})
@@ -9,6 +7,8 @@ foreach(pkg_config_lib CAIRO)
     link_directories(${${pkg_config_lib}_LIBRARY_DIRS})
 endforeach()
 
+# test suite
+
 set(testsuite_SOURCES
     harness.cpp
     core/expr/test.cpp
@@ -110,3 +110,20 @@ if(ENABLE_COVERAGE)
         COMMENT "Generating coverage report"
         VERBATIM)
 endif()
+
+# debug runner
+
+set(debugtool_SOURCES
+    debugtool.cpp
+)
+
+add_executable(solvespace-debugtool
+    ${debugtool_SOURCES}
+    $<TARGET_PROPERTY:resources,EXTRA_SOURCES>)
+
+target_link_libraries(solvespace-debugtool
+    solvespace-core
+    solvespace-headless)
+
+add_dependencies(solvespace-debugtool
+    resources)
diff --git a/test/debugtool.cpp b/test/debugtool.cpp
new file mode 100644
index 0000000..9553dbd
--- /dev/null
+++ b/test/debugtool.cpp
@@ -0,0 +1,26 @@
+//-----------------------------------------------------------------------------
+// Our entry point for exposing various internal mechanisms.
+//
+// Copyright 2017 whitequark
+//-----------------------------------------------------------------------------
+#include "solvespace.h"
+
+int main(int argc, char **argv) {
+    std::vector<std::string> args = InitPlatform(argc, argv);
+
+    if(args.size() == 3 && args[1] == "expr") {
+        std::string expr = args[2];
+        fprintf(stderr, "%g\n", Expr::From(expr.c_str(), false)->Eval());
+        FreeAllTemporary();
+    } else {
+        fprintf(stderr, "Usage: %s <command> <options>\n", args[0].c_str());
+//-----------------------------------------------------------------------------> 80 col */
+        fprintf(stderr, R"(
+    Commands:
+    expr [expr]
+        Evaluate an expression.
+)");
+    }
+
+    return 0;
+}