diff --git a/external/openscad_cpp_parser b/external/openscad_cpp_parser index 0789702..b621014 160000 --- a/external/openscad_cpp_parser +++ b/external/openscad_cpp_parser @@ -1 +1 @@ -Subproject commit 0789702ee90854c78d703e8ae56271b3f3b15db7 +Subproject commit b6210145687e73b1034dca2687d018d25fa6fdc9 diff --git a/include/openscad_cpp_evaluator/value.hpp b/include/openscad_cpp_evaluator/value.hpp index e899bf2..91191c4 100644 --- a/include/openscad_cpp_evaluator/value.hpp +++ b/include/openscad_cpp_evaluator/value.hpp @@ -181,6 +181,26 @@ Value matmul(const Value& a, const Value& b); // script's output is shown to land on one. std::string formatNumber(double v); +// Resolve the escape sequences in a string literal's source text. +// +// The parser stores a StringLiteral's text exactly as written, backslashes +// and all, so that a literal still reproduces the source it came from and +// pretty-printing round-trips. Cooking is therefore this side's job, and +// until this existed every escape reached scripts intact: "a\nb" was four +// characters, a backslash and an 'n' among them, rather than three. +// +// \\ \" \n \t \r the character they name +// \ nothing at all -- a line continuation, so the string +// carries on with no break in it. A CRLF goes whole: +// leaving the CR behind, as the reference +// implementation does, puts a stray control character +// into the value of any script written on Windows. +// \ that character, with the backslash dropped +// +// A trailing lone backslash is kept as itself. It can only occur in an +// unterminated literal, which is a parse error long before this runs. +std::string unescapeStringLiteral(const std::string& raw); + // echo()/str()/assert-message display format: "undef" | "true"/"false" | // "[start : step : end]" (range) | formatNumber() (number) | "[e1, e2, ...]" // (list, recursive) | "object(k1 = v1, ...)" ("object()" if empty) | diff --git a/pyproject.toml b/pyproject.toml index 12cf7cb..8540b9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "0.27.1" +version = "0.28.0" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/src/bytecode_compiler.cpp b/src/bytecode_compiler.cpp index d96f64d..278998d 100644 --- a/src/bytecode_compiler.cpp +++ b/src/bytecode_compiler.cpp @@ -324,7 +324,10 @@ class Compiler { out.push_back({Op::PushBool, static_cast(node).val ? 1 : 0, 0, nullptr}); return; case NodeKind::StringLiteral: - out.push_back({Op::PushConst, internConst(Value{static_cast(node).val}), 0, + out.push_back({Op::PushConst, + internConst(Value{unescapeStringLiteral( + static_cast(node).val)}), + 0, nullptr}); return; case NodeKind::UndefinedLiteral: diff --git a/src/expr_eval.cpp b/src/expr_eval.cpp index 1bf3b0c..1c2e52f 100644 --- a/src/expr_eval.cpp +++ b/src/expr_eval.cpp @@ -396,7 +396,7 @@ Value Evaluator::evalExpr(const oscad::Expression& node, EvalContext& ctx) { case NodeKind::BooleanLiteral: return Value{static_cast(node).val}; case NodeKind::StringLiteral: - return Value{static_cast(node).val}; + return Value{unescapeStringLiteral(static_cast(node).val)}; case NodeKind::UndefinedLiteral: return Value{}; case NodeKind::CommentedExpr: diff --git a/src/value.cpp b/src/value.cpp index aa2f768..d1c0a32 100644 --- a/src/value.cpp +++ b/src/value.cpp @@ -268,6 +268,40 @@ Value matmul(const Value& a, const Value& b) { return makeList(std::move(out)); } +std::string unescapeStringLiteral(const std::string& raw) { + // Most strings carry no escape at all, and this runs on every + // evaluation of a literal on the tree-walking path -- so don't build a + // second copy of the string unless there is something to change. + const size_t first = raw.find('\\'); + if (first == std::string::npos) return raw; + + std::string out; + out.reserve(raw.size()); + out.append(raw, 0, first); + for (size_t i = first; i < raw.size(); ++i) { + if (raw[i] != '\\' || i + 1 >= raw.size()) { + out.push_back(raw[i]); // a trailing lone backslash stands for itself + continue; + } + const char next = raw[i + 1]; + switch (next) { + case 'n': out.push_back('\n'); ++i; break; + case 't': out.push_back('\t'); ++i; break; + case 'r': out.push_back('\r'); ++i; break; + case '\n': ++i; break; // line continuation: contributes nothing + case '\r': + // Only a CRLF pair is a line continuation; a lone CR is an + // ordinary escaped character like any other. + if (i + 2 < raw.size() && raw[i + 2] == '\n') { i += 2; break; } + out.push_back('\r'); + ++i; + break; + default: out.push_back(next); ++i; break; // \\ and \" land here too + } + } + return out; +} + std::string formatNumber(double v) { if (std::isnan(v)) return "nan"; if (std::isinf(v)) return v > 0 ? "inf" : "-inf"; diff --git a/tests/test_bytecode_compiler.cpp b/tests/test_bytecode_compiler.cpp index 421d0b1..aef7791 100644 --- a/tests/test_bytecode_compiler.cpp +++ b/tests/test_bytecode_compiler.cpp @@ -1044,7 +1044,7 @@ TEST(BytecodeCompiler, ImportCompilesAsTailPositionBody) { std::ofstream out(path); out << R"({"a": 1, "b": 2})"; } - EXPECT_EQ(runCapturingEcho("function f() = import(\"" + path.string() + "\");\necho(f());"), + EXPECT_EQ(runCapturingEcho("function f() = import(\"" + path.generic_string() + "\");\necho(f());"), "ECHO: object(a = 1, b = 2)"); std::filesystem::remove(path); } @@ -1073,7 +1073,7 @@ TEST(BytecodeCompiler, VmOffAndVmOnAgreeOnEchoAssertImportObjectCases) { const std::string script = "function withEcho(x) = echo(\"trace\", x) x + 1;\n" "function withAssert(x) = assert(x >= 0, \"must be non-negative\") x * 2;\n" "function withImport() = import(\"" + - path.string() + + path.generic_string() + "\");\n" "existing = object(m = 1);\n" "function withObject() = object(n = 2, existing, m = 3);\n" diff --git a/tests/test_dxf_svg_import.cpp b/tests/test_dxf_svg_import.cpp index aafca3e..3d01345 100644 --- a/tests/test_dxf_svg_import.cpp +++ b/tests/test_dxf_svg_import.cpp @@ -38,7 +38,7 @@ const char* kMinimalDxfSquare = TEST(DxfImport, ClosedLwpolylineProducesExpectedBoundingBox) { const auto path = tempPath("square.dxf"); writeFile(path, kMinimalDxfSquare); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); // 4x3 square @@ -49,7 +49,7 @@ TEST(DxfImport, ExpressionContextReturnsRegion) { const auto path = tempPath("square_expr.dxf"); writeFile(path, kMinimalDxfSquare); Evaluator ev; - Value v = asExpr("import(\"" + path.string() + "\")", ev); + Value v = asExpr("import(\"" + path.generic_string() + "\")", ev); const auto& contours = std::get(v)->items; ASSERT_EQ(contours.size(), 1u); const auto& pts = std::get(contours[0])->items; @@ -68,7 +68,7 @@ TEST(DxfImport, LayerFilterExcludesOtherLayers) { "0\nENDSEC\n0\nEOF\n"; const auto path = tempPath("layered.dxf"); writeFile(path, dxf); - Evaluated e = evalSrc("import(file=\"" + path.string() + "\", layer=\"keep\");"); + Evaluated e = evalSrc("import(file=\"" + path.generic_string() + "\", layer=\"keep\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 1.0, 1e-6); @@ -90,7 +90,7 @@ TEST(DxfImport, ClosedPolylineVertexEntityProducesExpectedArea) { "0\nENDSEC\n0\nEOF\n"; const auto path = tempPath("polyline.dxf"); writeFile(path, dxf); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); @@ -111,7 +111,7 @@ TEST(DxfImport, OpenPolylineIsIgnored) { const auto path = tempPath("open_polyline.dxf"); writeFile(path, dxf); Evaluator ev; - auto ast = parseSrc("import(\"" + path.string() + "\");"); + auto ast = parseSrc("import(\"" + path.generic_string() + "\");"); auto scope = oscad::buildScopes(ast); EvalContext ctx = EvalContext::makeRoot(scope.get()); EXPECT_THROW(ev.resolveTree(ast, ctx), EvalError); @@ -131,7 +131,7 @@ TEST(DxfImport, UnrecognizedEntityIsSkipped) { "0\nENDSEC\n0\nEOF\n"; const auto path = tempPath("unrecognized.dxf"); writeFile(path, dxf); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); @@ -143,7 +143,7 @@ TEST(DxfImport, NoClosedContoursErrors) { const auto path = tempPath("empty.dxf"); writeFile(path, dxf); Evaluator ev; - auto ast = parseSrc("import(\"" + path.string() + "\");"); + auto ast = parseSrc("import(\"" + path.generic_string() + "\");"); auto scope = oscad::buildScopes(ast); EvalContext ctx = EvalContext::makeRoot(scope.get()); EXPECT_THROW(ev.resolveTree(ast, ctx), EvalError); @@ -155,7 +155,7 @@ TEST(DxfImport, NoClosedContoursErrors) { TEST(SvgImport, RectProducesExpectedArea) { const auto path = tempPath("rect.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); @@ -167,7 +167,7 @@ TEST(SvgImport, PathWithGroupTransformIsTranslatedAndYFlipped) { writeFile(path, R"svg( )svg"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); manifold::Rect bounds = e.bodies[0].section->Bounds(); @@ -183,7 +183,7 @@ TEST(SvgImport, PathWithGroupTransformIsTranslatedAndYFlipped) { TEST(SvgImport, CircleApproximatesAnalyticArea) { const auto path = tempPath("circle.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 3.14159265 * 25.0, 1.0); // 32-segment approximation std::filesystem::remove(path); @@ -192,7 +192,7 @@ TEST(SvgImport, CircleApproximatesAnalyticArea) { TEST(SvgImport, CubicBezierPathIsWatertight) { const auto path = tempPath("bezier.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_GT(e.bodies[0].section->Area(), 0.0); @@ -203,7 +203,7 @@ TEST(SvgImport, NoShapesErrors) { const auto path = tempPath("noshapes.svg"); writeFile(path, R"()"); Evaluator ev; - auto ast = parseSrc("import(\"" + path.string() + "\");"); + auto ast = parseSrc("import(\"" + path.generic_string() + "\");"); auto scope = oscad::buildScopes(ast); EvalContext ctx = EvalContext::makeRoot(scope.get()); EXPECT_THROW(ev.resolveTree(ast, ctx), EvalError); @@ -220,7 +220,7 @@ TEST(SvgImport, EntityReferencesInAttributeValueDecodedWithoutCorruptingParse) { // area would come out wrong (or parsing would throw). const auto path = tempPath("entities.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); @@ -241,7 +241,7 @@ TEST(SvgImport, CommentCdataPrologAndDoctypeAreSkipped) { )"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); @@ -253,7 +253,7 @@ TEST(SvgImport, TransformMatrixIsApplied) { writeFile(path, R"svg( )svg"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 48.0, 1e-6); // 2x scale in both axes -> 4x area manifold::Rect bounds = e.bodies[0].section->Bounds(); @@ -267,7 +267,7 @@ TEST(SvgImport, TransformScaleWithSeparateXyIsApplied) { writeFile(path, R"svg( )svg"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 72.0, 1e-6); // 2x * 3x -> 6x area std::filesystem::remove(path); @@ -278,7 +278,7 @@ TEST(SvgImport, TransformRotateAboutOriginIsApplied) { writeFile(path, R"svg( )svg"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); // rotation preserves area manifold::Rect bounds = e.bodies[0].section->Bounds(); @@ -294,7 +294,7 @@ TEST(SvgImport, TransformRotateAboutOriginIsApplied) { TEST(SvgImport, HorizontalAndVerticalLineCommands) { const auto path = tempPath("hv.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); std::filesystem::remove(path); @@ -303,7 +303,7 @@ TEST(SvgImport, HorizontalAndVerticalLineCommands) { TEST(SvgImport, SmoothCubicCommandFollowsCubic) { const auto path = tempPath("smoothcubic.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_GT(e.bodies[0].section->Area(), 0.0); @@ -313,7 +313,7 @@ TEST(SvgImport, SmoothCubicCommandFollowsCubic) { TEST(SvgImport, SmoothQuadraticCommandFollowsQuadratic) { const auto path = tempPath("smoothquad.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_GT(e.bodies[0].section->Area(), 0.0); @@ -323,7 +323,7 @@ TEST(SvgImport, SmoothQuadraticCommandFollowsQuadratic) { TEST(SvgImport, EllipticalArcCommand) { const auto path = tempPath("arc.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_GT(e.bodies[0].section->Area(), 0.0); @@ -333,7 +333,7 @@ TEST(SvgImport, EllipticalArcCommand) { TEST(SvgImport, PolygonPointsAttribute) { const auto path = tempPath("polygon.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); std::filesystem::remove(path); @@ -342,7 +342,7 @@ TEST(SvgImport, PolygonPointsAttribute) { TEST(SvgImport, PolylinePointsAttribute) { const auto path = tempPath("polyline.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); std::filesystem::remove(path); @@ -351,7 +351,7 @@ TEST(SvgImport, PolylinePointsAttribute) { TEST(SvgImport, StrayCharacterInPointsListIsSkipped) { const auto path = tempPath("straypoints.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); std::filesystem::remove(path); @@ -360,7 +360,7 @@ TEST(SvgImport, StrayCharacterInPointsListIsSkipped) { TEST(SvgImport, StrayCharacterInPathDataIsSkipped) { const auto path = tempPath("straypath.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); std::filesystem::remove(path); @@ -374,7 +374,7 @@ TEST(SvgImport, TransformNumberWithExponentIsParsed) { writeFile(path, R"svg( )svg"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); manifold::Rect bounds = e.bodies[0].section->Bounds(); EXPECT_NEAR(bounds.min.x, 10.0, 1e-6); @@ -386,7 +386,7 @@ TEST(SvgImport, PathNumberWithExponentIsParsed) { // path `d` attribute rather than a transform argument list. const auto path = tempPath("exponent_path.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 12.0, 1e-6); std::filesystem::remove(path); @@ -400,7 +400,7 @@ TEST(SvgImport, EllipticalArcWithOutOfRangeRadiiIsScaledUp) { // never hit this path. const auto path = tempPath("arc_scaled.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_GT(e.bodies[0].section->Area(), 0.0); @@ -413,7 +413,7 @@ TEST(SvgImport, MultipleSubpathsInOnePathProduceMultipleContours) { // starting a new one, distinct from an explicit `Z`. const auto path = tempPath("multisubpath.svg"); writeFile(path, R"()"); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].section.has_value()); EXPECT_NEAR(e.bodies[0].section->Area(), 24.0, 1e-6); // two disjoint 4x3 squares diff --git a/tests/test_import_export.cpp b/tests/test_import_export.cpp index db9914b..25b06fa 100644 --- a/tests/test_import_export.cpp +++ b/tests/test_import_export.cpp @@ -40,7 +40,7 @@ Value asExpr(const std::string& code, Evaluator& ev) { TEST(ImportModuleContext, StlRoundTripPreservesVolume) { const auto path = tempPath("cube.stl"); writeCubeAs(path, &writeStl); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); EXPECT_NEAR(e.bodies[0].body->Volume(), 8.0, 1e-6); @@ -50,7 +50,7 @@ TEST(ImportModuleContext, StlRoundTripPreservesVolume) { TEST(ImportModuleContext, ObjRoundTripPreservesVolume) { const auto path = tempPath("cube.obj"); writeCubeAs(path, &writeObj); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); EXPECT_NEAR(e.bodies[0].body->Volume(), 8.0, 1e-6); @@ -60,7 +60,7 @@ TEST(ImportModuleContext, ObjRoundTripPreservesVolume) { TEST(ImportModuleContext, OffRoundTripPreservesVolume) { const auto path = tempPath("cube.off"); writeCubeAs(path, &writeOff); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); EXPECT_NEAR(e.bodies[0].body->Volume(), 8.0, 1e-6); @@ -70,7 +70,7 @@ TEST(ImportModuleContext, OffRoundTripPreservesVolume) { TEST(ImportModuleContext, ThreeMfRoundTripPreservesVolume) { const auto path = tempPath("cube.3mf"); writeCubeAs(path, &writeThreeMf); - Evaluated e = evalSrc("import(\"" + path.string() + "\");"); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); EXPECT_NEAR(e.bodies[0].body->Volume(), 8.0, 1e-6); @@ -100,7 +100,7 @@ TEST(ImportModuleContext, JsonExtensionErrorsAsGeometryStatement) { out << R"({"a": 1})"; } Evaluator ev; - auto ast = parseSrc("import(\"" + path.string() + "\");"); + auto ast = parseSrc("import(\"" + path.generic_string() + "\");"); auto scope = oscad::buildScopes(ast); EvalContext ctx = EvalContext::makeRoot(scope.get()); EXPECT_THROW(ev.resolveTree(ast, ctx), EvalError); @@ -118,7 +118,7 @@ TEST(ImportModuleContext, MalformedMeshFileErrors) { out << "this is not a valid STL file at all"; } Evaluator ev; - auto ast = parseSrc("import(\"" + path.string() + "\");"); + auto ast = parseSrc("import(\"" + path.generic_string() + "\");"); auto scope = oscad::buildScopes(ast); EvalContext ctx = EvalContext::makeRoot(scope.get()); EXPECT_THROW(ev.resolveTree(ast, ctx), EvalError); @@ -136,7 +136,7 @@ TEST(ImportModuleContext, NonManifoldMeshWarns) { out << "OFF\n3 1 0\n0 0 0\n1 0 0\n0 1 0\n3 0 1 2\n"; } std::string lastWarning; - Evaluated e = evalSrc("import(\"" + path.string() + "\");", [&](const std::string& msg) { lastWarning = msg; }); + Evaluated e = evalSrc("import(\"" + path.generic_string() + "\");", [&](const std::string& msg) { lastWarning = msg; }); EXPECT_NE(lastWarning.find("import: mesh is not a closed solid"), std::string::npos); // The triangle is now handed back for display rather than dropped: a // file that warns once and then shows nothing gives no way to see what @@ -158,7 +158,7 @@ TEST(ImportModuleContext, EmptyMeshHasNoTrianglesErrors) { std::ofstream out(path); out << "OFF\n0 0 0\n"; } - EXPECT_THROW(evalSrc("import(\"" + path.string() + "\");"), EvalError); + EXPECT_THROW(evalSrc("import(\"" + path.generic_string() + "\");"), EvalError); std::filesystem::remove(path); } @@ -168,7 +168,7 @@ TEST(ImportExpressionContext, StlReturnsVnfShape) { const auto path = tempPath("cube_vnf.stl"); writeCubeAs(path, &writeStl); Evaluator ev; - Value v = asExpr("import(\"" + path.string() + "\")", ev); + Value v = asExpr("import(\"" + path.generic_string() + "\")", ev); const auto& outer = std::get(v)->items; ASSERT_EQ(outer.size(), 2u); const auto& verts = std::get(outer[0])->items; @@ -185,7 +185,7 @@ TEST(ImportExpressionContext, JsonReturnsNativeValues) { out << R"({"name": "x", "n": 3, "nested": {"a": 1, "b": 2}, "list": [1, 2, 3]})"; } Evaluator ev; - Value v = asExpr("import(\"" + path.string() + "\")", ev); + Value v = asExpr("import(\"" + path.generic_string() + "\")", ev); const auto& obj = std::get(v)->items; ASSERT_EQ(obj.size(), 4u); EXPECT_EQ(obj[0].first, "name"); @@ -211,7 +211,7 @@ TEST(ImportExpressionContext, JsonNullValueBecomesUndef) { out << R"({"x": null})"; } Evaluator ev; - Value v = asExpr("import(\"" + path.string() + "\")", ev); + Value v = asExpr("import(\"" + path.generic_string() + "\")", ev); const auto& obj = std::get(v)->items; ASSERT_EQ(obj.size(), 1u); EXPECT_TRUE(std::holds_alternative(obj[0].second)); @@ -227,7 +227,7 @@ TEST(ImportExpressionContext, DxfReturnsRegionContours) { "0\nENDSEC\n0\nEOF\n"; } Evaluator ev; - Value v = asExpr("import(\"" + path.string() + "\")", ev); + Value v = asExpr("import(\"" + path.generic_string() + "\")", ev); const auto& contours = std::get(v)->items; ASSERT_EQ(contours.size(), 1u); std::filesystem::remove(path); @@ -250,7 +250,7 @@ TEST(ImportExpressionContext, MalformedMeshFileErrors) { out << "not a valid STL file"; } Evaluator ev; - EXPECT_THROW(asExpr("import(\"" + path.string() + "\")", ev), EvalError); + EXPECT_THROW(asExpr("import(\"" + path.generic_string() + "\")", ev), EvalError); std::filesystem::remove(path); } diff --git a/tests/test_surface.cpp b/tests/test_surface.cpp index f17a909..2c4677e 100644 --- a/tests/test_surface.cpp +++ b/tests/test_surface.cpp @@ -46,7 +46,7 @@ TEST(Surface, DatFileHeightsMatchExactVertexPositions) { // reference on this shape (12 triangles, bbox [0,0,0]-[2,2,5]). const auto path = tempPath("terrain.dat"); writeDat(path, {{0, 0, 0}, {0, 5, 0}, {0, 0, 0}}); - Evaluated e = evalSrc("surface(file=\"" + path.string() + "\", center=false);"); + Evaluated e = evalSrc("surface(file=\"" + path.generic_string() + "\", center=false);"); ASSERT_EQ(e.bodies.size(), 1u); ASSERT_TRUE(e.bodies[0].body.has_value()); manifold::Box bbox = e.bodies[0].body->BoundingBox(); @@ -60,7 +60,7 @@ TEST(Surface, DatFileHeightsMatchExactVertexPositions) { TEST(Surface, CenterTrueOffsetsXyAroundOrigin) { const auto path = tempPath("terrain_center.dat"); writeDat(path, {{1, 1, 1}, {1, 1, 1}, {1, 1, 1}}); - Evaluated e = evalSrc("surface(file=\"" + path.string() + "\", center=true);"); + Evaluated e = evalSrc("surface(file=\"" + path.generic_string() + "\", center=true);"); manifold::Box bbox = e.bodies[0].body->BoundingBox(); EXPECT_NEAR(bbox.min.x, -1.0, 1e-9); // (cols-1)/2 = 1 EXPECT_NEAR(bbox.max.x, 1.0, 1e-9); @@ -70,7 +70,7 @@ TEST(Surface, CenterTrueOffsetsXyAroundOrigin) { TEST(Surface, FlatGridVolumeMatchesFootprintTimesHeight) { const auto path = tempPath("flat.dat"); writeDat(path, {{3, 3, 3}, {3, 3, 3}, {3, 3, 3}}); - Evaluated e = evalSrc("surface(file=\"" + path.string() + "\");"); + Evaluated e = evalSrc("surface(file=\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].body.has_value()); EXPECT_NEAR(e.bodies[0].body->Volume(), 2.0 * 2.0 * 3.0, 1e-6); // (cols-1)*(rows-1)*height std::filesystem::remove(path); @@ -80,7 +80,7 @@ TEST(Surface, EmptyFileErrors) { const auto path = tempPath("empty.dat"); writeDat(path, {}); Evaluator ev; - auto ast = parseSrc("surface(file=\"" + path.string() + "\");"); + auto ast = parseSrc("surface(file=\"" + path.generic_string() + "\");"); auto scope = oscad::buildScopes(ast); EvalContext ctx = EvalContext::makeRoot(scope.get()); EXPECT_THROW(ev.resolveTree(ast, ctx), EvalError); @@ -92,7 +92,7 @@ TEST(Surface, EmptyFileErrors) { TEST(Surface, UniformGrayImageVolumeMatchesLuminanceFormula) { const auto path = tempPath("flat.png"); writeGrayscalePng(path, 3, 3, std::vector(9, 128)); - Evaluated e = evalSrc("surface(file=\"" + path.string() + "\");"); + Evaluated e = evalSrc("surface(file=\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].body.has_value()); // gray=128 (r=g=b, so the weighted luminance formula reduces to the // input value exactly) -> height = 128/255*100. @@ -104,8 +104,8 @@ TEST(Surface, UniformGrayImageVolumeMatchesLuminanceFormula) { TEST(Surface, InvertFlipsTheHeightMapping) { const auto path = tempPath("flat2.png"); writeGrayscalePng(path, 3, 3, std::vector(9, 200)); - Evaluated normal = evalSrc("surface(file=\"" + path.string() + "\", invert=false);"); - Evaluated inverted = evalSrc("surface(file=\"" + path.string() + "\", invert=true);"); + Evaluated normal = evalSrc("surface(file=\"" + path.generic_string() + "\", invert=false);"); + Evaluated inverted = evalSrc("surface(file=\"" + path.generic_string() + "\", invert=true);"); const double heightNormal = 200.0 / 255.0 * 100.0; const double heightInverted = (255.0 - 200.0) / 255.0 * 100.0; EXPECT_NEAR(normal.bodies[0].body->Volume(), 2.0 * 2.0 * heightNormal, heightNormal * 2e-3); @@ -121,7 +121,7 @@ TEST(Surface, ImageRowOrderMatchesBottomRowIsYZero) { const auto path = tempPath("rowcheck.png"); std::vector px = {0, 0, 255, 255}; // row0: black,black ; row1: white,white writeGrayscalePng(path, 2, 2, px); - Evaluated e = evalSrc("surface(file=\"" + path.string() + "\");"); + Evaluated e = evalSrc("surface(file=\"" + path.generic_string() + "\");"); ASSERT_TRUE(e.bodies[0].body.has_value()); // Slice a thin box at low Y (y in [0,0.5]) and confirm it reaches the // white row's full height (100), not the black row's height (0). diff --git a/tests/test_value.cpp b/tests/test_value.cpp index 010bb7c..30a78a9 100644 --- a/tests/test_value.cpp +++ b/tests/test_value.cpp @@ -1,5 +1,7 @@ #include "openscad_cpp_evaluator/value.hpp" +#include "test_helpers.hpp" + #include #include #include @@ -434,3 +436,63 @@ TEST(ExpandIterable, ZeroStepRangeIsNaturallyEmptyNotTooMany) { EXPECT_FALSE(warned); EXPECT_EQ(items.size(), 0u); } + +// -- String literal escapes ---------------------------------------------- + +// The parser hands back a literal's source text with its backslashes +// intact, so that a StringLiteral still reproduces the source and +// pretty-printing round-trips. Nothing resolved them, so every escape +// reached scripts raw: "a\nb" was four characters, a backslash and an 'n' +// among them. +TEST(StringEscapes, EachSequenceResolvesToTheCharacterItNames) { + struct Case { const char* raw; const char* want; const char* what; }; + const Case cases[] = { + {R"(a\\b)", "a\\b", "backslash"}, + {R"(a\nb)", "a\nb", "newline"}, + {R"(a\tb)", "a\tb", "tab"}, + {R"(a\rb)", "a\rb", "carriage return"}, + {R"(a\"b)", "a\"b", "quote"}, + {R"(a\qb)", "aqb", "unknown escape keeps the character, drops the backslash"}, + {"plain", "plain", "no escape at all"}, + {"", "", "empty"}, + {R"(\\)", "\\", "nothing but an escape"}, + {"a\\", "a\\", "trailing lone backslash stands for itself"}, + }; + for (const Case& c : cases) + EXPECT_EQ(unescapeStringLiteral(c.raw), c.want) << c.what; +} + +// A backslash escaping the end of a line continues the string with no +// break in it -- the newline contributes nothing. +TEST(StringEscapes, BackslashNewlineContributesNothing) { + EXPECT_EQ(unescapeStringLiteral("a \\\nb"), "a b") << "LF"; + EXPECT_EQ(unescapeStringLiteral("a \\\r\nb"), "a b") << "CRLF goes whole"; + // Deliberately unlike the reference implementation, which drops only + // the LF and leaves the CR in the value -- a stray control character + // in any string continued in a file written on Windows. + EXPECT_EQ(unescapeStringLiteral("a \\\rb"), "a \rb") + << "a lone CR is an ordinary escaped character, not a line ending"; +} + +// Both evaluation paths build the Value, and only one of them was reached +// by a first attempt at this. +TEST(StringEscapes, BothEvaluationPathsCookTheLiteral) { + // Tree-walking path: a literal at top level. + std::vector echoed; + auto sink = [&echoed](const std::string& m) { echoed.push_back(m); }; + oscadeval::test::evaluateSrc("echo(len(\"a\\nb\"));", sink); + ASSERT_EQ(echoed.size(), 1u); + EXPECT_EQ(echoed[0], "ECHO: 3") << "tree-walking path"; + + // Compiled path: inside a function body, which is what gets compiled + // to bytecode. + echoed.clear(); + oscadeval::test::evaluateSrc("function f() = len(\"a\\nb\");\necho(f());", sink); + ASSERT_EQ(echoed.size(), 1u); + EXPECT_EQ(echoed[0], "ECHO: 3") << "compiled path"; + + echoed.clear(); + oscadeval::test::evaluateSrc("function g() = len(\"a \\\nb\");\necho(g());", sink); + ASSERT_EQ(echoed.size(), 1u); + EXPECT_EQ(echoed[0], "ECHO: 3") << "line continuation, compiled path"; +}