Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"

[project]
name = "openscad_cpp_evaluator"
version = "1.20.0"
version = "1.20.1"
description = "C++ OpenSCAD evaluator with Python bindings"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
40 changes: 18 additions & 22 deletions src/value.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -410,24 +410,28 @@ std::string encodeEscapedCodePoint(std::uint32_t cp) {
std::string unescapeStringLiteral(const std::string& raw) {
// Most strings need no work at all, and this runs on every evaluation
// of a literal on the tree-walking path -- so don't build a second copy
// unless there is something to change. A bare newline counts as
// something to change (see the loop), so it has to open the scan too.
const size_t first = raw.find_first_of("\\\n");
// unless there is something to change. A bare line ending counts as
// something to change (see the loop), so CR and LF open the scan too.
const size_t first = raw.find_first_of("\\\n\r");
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) {
// A raw LF inside a string literal contributes NOTHING -- writing a
// string across two source lines joins them, keeping the second
// line's indentation:
// A raw line ending inside a string literal contributes NOTHING --
// writing a string across two source lines joins them, keeping the
// second line's indentation:
// s = "abcd
// efgh"; // -> "abcd efgh", 12 characters
// A raw CR is not special and stands for itself, so a CRLF file
// leaves the CR behind (len 3 for "x<CR><LF>y"). Both verified on
// 2026.02.01.
if (raw[i] == '\n') continue;
// CR counts as a line ending too, which the reference does not do:
// it drops only the LF, so every string wrapped in a file written
// on Windows keeps a stray CR (len 3 for "x<CR><LF>y" there, 2
// here). A raw CR in source is a line ending in every real file --
// CRLF on Windows, a lone CR on a pre-OSX Mac -- and a string that
// wants a real CR in it writes \r. Same reasoning as the backslash
// continuation below.
if (raw[i] == '\n' || raw[i] == '\r') continue;
if (raw[i] != '\\' || i + 1 >= raw.size()) {
out.push_back(raw[i]); // a trailing lone backslash stands for itself
continue;
Expand Down Expand Up @@ -467,18 +471,10 @@ std::string unescapeStringLiteral(const std::string& raw) {
++i;
break;
}
case '\n': ++i; break; // backslash + LF: both go
case '\r':
// A backslash before CRLF takes the whole line ending.
// Deliberately unlike the reference, which treats the
// backslash as an undefined escape and keeps the CR --
// leaving a stray control character in any string wrapped
// in a file written on Windows. See this suite's
// BackslashNewlineContributesNothing.
if (i + 2 < raw.size() && raw[i + 2] == '\n') { i += 2; break; }
out.push_back('\r');
++i;
break;
// A backslash before a line ending takes the whole thing; any
// LF after a CR is dropped by the loop itself.
case '\n':
case '\r': ++i; break;
default: out.push_back(next); ++i; break; // \\ and \" land here too
}
}
Expand Down
21 changes: 13 additions & 8 deletions tests/test_value.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,8 @@ TEST(StringEscapes, BackslashNewlineContributesNothing) {
// 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";
EXPECT_EQ(unescapeStringLiteral("a \\\rb"), "a b")
<< "a lone CR is a line ending too (pre-OSX Mac)";
}

// Both evaluation paths build the Value, and only one of them was reached
Expand Down Expand Up @@ -583,11 +583,16 @@ TEST(StringEscapes, ARawNewlineInsideALiteralContributesNothing) {
EXPECT_EQ(unescapeStringLiteral("x\n \n y"), "x y");
}

TEST(StringEscapes, ARawCarriageReturnStandsForItself) {
// Only the LF is special. A CRLF file therefore leaves the CR in the
// string: "x<CR><LF>y" is three characters, not two.
EXPECT_EQ(unescapeStringLiteral("x\ry"), "x\ry");
EXPECT_EQ(unescapeStringLiteral("x\r\ny"), "x\ry");
TEST(StringEscapes, ARawCarriageReturnIsALineEndingToo) {
// The reference treats only the LF as special, so a string wrapped in
// a CRLF file keeps a stray CR there ("x<CR><LF>y" is three characters
// on 2026.02.01). Deliberately not reproduced: a raw CR in source is a
// line ending in every real file -- CRLF on Windows, a lone CR on a
// pre-OSX Mac -- and a script wanting a real CR writes \r.
EXPECT_EQ(unescapeStringLiteral("x\r\ny"), "xy");
EXPECT_EQ(unescapeStringLiteral("x\ry"), "xy");
EXPECT_EQ(unescapeStringLiteral("x\r\n y"), "x y") << "indentation is kept";
EXPECT_EQ(unescapeStringLiteral("x\\ry"), "x\ry") << "an escaped CR still works";
}

TEST(StringEscapes, ABackslashBeforeANewlineTakesTheWholeLineEnding) {
Expand All @@ -600,5 +605,5 @@ TEST(StringEscapes, ABackslashBeforeANewlineTakesTheWholeLineEnding) {
// BackslashNewlineContributesNothing above.
EXPECT_EQ(unescapeStringLiteral("x\\\ny"), "xy");
EXPECT_EQ(unescapeStringLiteral("x\\\r\ny"), "xy"); // reference keeps the CR
EXPECT_EQ(unescapeStringLiteral("x\\\ry"), "x\ry");
EXPECT_EQ(unescapeStringLiteral("x\\\ry"), "xy");
}