diff --git a/docs/roadmap.md b/docs/roadmap.md index 4b4f7bb..06310d4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,6 +67,41 @@ else. - [x] PNG heightmap support for `surface()` - [x] Additional `import()` formats: OFF, 3MF, AMF, DXF, SVG +## v3.5 — OpenSCAD Language Completeness (audit follow-up) ✓ + +The v3 phases above closed the 33 filed correctness issues, but "feature +complete with OpenSCAD" was never verified against OpenSCAD's actual +builtin/special-variable surface — a July 2026 completeness audit (source +read, not just the issue tracker) found real gaps the v3 checklist didn't +cover. Fixed: + +- [x] `PI` constant (was entirely absent — scripts had to hard-code 3.14159) +- [x] `is_undef()`/`is_bool()`/`is_num()`/`is_string()`/`is_list()`/`is_function()` type predicates +- [x] `search()` (string/list/table search, matching OpenSCAD's flat-vs-nested result shape) +- [x] `version()`/`version_num()` (fixed compatibility level, so version-gated library code picks its modern branch) +- [x] `$preview`/`$t` special variables (fixed defaults: no animation/dual-render-pass distinction exists yet) +- [x] `parent_module(idx)`/`$parent_modules` +- [x] `linear_extrude(slices=...)` — was parsed but silently ignored; twist division count now honors it over the `$fn` default +- [x] `children([vector])` / `children([range])` — only a single plain-number index worked before +- [x] `echo(name=value)` now formats as `name = value`, matching OpenSCAD + +**Explicitly deferred, not done in this pass** (tracked here so they aren't +lost, not because they don't matter): +- First-class function literals (`f = function(x) x*2;`, OpenSCAD 2019.05+) + as values assignable to variables/passable as arguments — the language + has named `function foo(x) = expr;` defs but no closure value type; adding + one touches the `Value` variant, the parser grammar, and call dispatch + broadly enough that it deserves its own pass rather than riding along with + smaller fixes. +- `$vpr`/`$vpt`/`$vpd` viewport special variables — would need the render + layer's camera state plumbed into the interpreter, a cross-subsystem wire + that doesn't exist today; rarely load-bearing in real `.scad` files. +- `cube`/`sphere`/`translate`/etc. are reserved lexer keywords rather than + ordinary identifiers, so (unlike real OpenSCAD) a script can't use one of + those names as a variable. Real-world impact is low, but it's a genuine + parser-level deviation and any fix is a grammar-level change, not a + point fix — out of scope here. + ## v4 — Tooling & Visual Quality - [ ] VS Code LSP extension (syntax highlighting, error squiggles, completions) diff --git a/src/csg/CsgEvaluator.cpp b/src/csg/CsgEvaluator.cpp index 130a252..965674d 100644 --- a/src/csg/CsgEvaluator.cpp +++ b/src/csg/CsgEvaluator.cpp @@ -655,6 +655,7 @@ CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::m Value v = m_interp->evaluate(*arg.value); msg += first ? " " : ", "; first = false; + if (!arg.name.empty()) msg += arg.name + " = "; msg += formatValue(v); } m_scene->echoMessages.push_back(std::move(msg)); @@ -761,6 +762,7 @@ CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::m // Expose $children count and push the children context for children() access m_interp->setVar("$children", Value::fromNumber(static_cast(call.children.size()))); m_childrenStack.push_back({&call.children, &savedEnv}); + m_interp->pushModuleName(call.name); // Evaluate the module body and collect geometry std::vector all; @@ -771,6 +773,7 @@ CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::m } --m_moduleDepth; + m_interp->popModuleName(); m_childrenStack.pop_back(); // Restore the caller's environment m_interp->restoreEnv(std::move(savedEnv)); @@ -808,11 +811,22 @@ CsgNodePtr CsgEvaluator::evalChildren(const ModuleCallNode& call, const glm::mat // children(i)'s index expression is written in the *callee* module body, // so evaluate it in the callee's (still-active) scope, before switching // to the caller's scope for the child AST nodes themselves. - std::optional idx; + // + // The index argument can be a plain number (children(i)), a vector of + // numbers (children([0,2])), or a range (children([0:2])) — collect + // whichever form into a flat list of indices to evaluate below. + std::vector indices; if (!call.args.empty()) { Value idxVal = m_interp->evaluate(*call.args[0].value); - if (idxVal.isNumber()) - idx = static_cast(idxVal.asNumber()); + if (idxVal.isNumber()) { + indices.push_back(static_cast(idxVal.asNumber())); + } else if (idxVal.isVector()) { + for (const auto& e : idxVal.asVec()) + if (e.isNumber()) indices.push_back(static_cast(e.asNumber())); + } else if (idxVal.isRange()) { + for (const auto& v : m_interp->expandRange(idxVal.rangeStart, idxVal.rangeStep, idxVal.rangeEnd)) + if (v.isNumber()) indices.push_back(static_cast(v.asNumber())); + } } // Children AST nodes were written at the call site and must be evaluated @@ -830,10 +844,15 @@ CsgNodePtr CsgEvaluator::evalChildren(const ModuleCallNode& call, const glm::mat if (auto c = evalNode(*child, xform, color)) all.push_back(std::move(c)); } - } else if (idx && *idx >= 0 && *idx < static_cast(activeChildren->size())) { - // children(i) — evaluate the i-th child - if (auto c = evalNode(*(*activeChildren)[static_cast(*idx)], xform, color)) - all.push_back(std::move(c)); + } else { + // children(i) / children([...]) / children([a:b]) — evaluate each + // requested index that's actually in range; out-of-range indices are + // skipped rather than aborting the whole call. + for (int idx : indices) { + if (idx < 0 || idx >= static_cast(activeChildren->size())) continue; + if (auto c = evalNode(*(*activeChildren)[static_cast(idx)], xform, color)) + all.push_back(std::move(c)); + } } // Restore the callee's environment now that caller-scope evaluation is done diff --git a/src/csg/MeshEvaluator.cpp b/src/csg/MeshEvaluator.cpp index 6e3cc3f..80f861e 100644 --- a/src/csg/MeshEvaluator.cpp +++ b/src/csg/MeshEvaluator.cpp @@ -412,11 +412,18 @@ manifold::Manifold MeshEvaluator::evalExtrusion(const CsgExtrusion& e, float scaleX = static_cast(getP("scale_x", 1.0)); float scaleY = static_cast(getP("scale_y", 1.0)); double fnOvr = getP("$fn", 0.0); + double slices = getP("slices", 0.0); // Divisions are only needed for twist (to smoothly interpolate the // rotation). A plain scale taper works correctly with 0 divisions. - int nDivs = (twist != 0.0) - ? std::max(1, static_cast(fnOvr > 0 ? fnOvr : 10)) - : 0; + // An explicit slices=... always wins over the $fn-derived default — + // it's OpenSCAD's dedicated knob for this and should be honored even + // when the caller also set $fn for an unrelated reason (e.g. a + // sibling circle() in the same file). + int nDivs = 0; + if (slices > 0.0) + nDivs = std::max(1, static_cast(slices)); + else if (twist != 0.0) + nDivs = std::max(1, static_cast(fnOvr > 0 ? fnOvr : 10)); bool center = (getP("center", 0.0) != 0.0); result = manifold::Manifold::Extrude( diff --git a/src/lang/Interpreter.cpp b/src/lang/Interpreter.cpp index d279064..e2c91a2 100644 --- a/src/lang/Interpreter.cpp +++ b/src/lang/Interpreter.cpp @@ -4,8 +4,9 @@ #include #include -static constexpr double kDeg2Rad = 3.14159265358979323846 / 180.0; -static constexpr double kRad2Deg = 180.0 / 3.14159265358979323846; +static constexpr double kPi = 3.14159265358979323846; +static constexpr double kDeg2Rad = kPi / 180.0; +static constexpr double kRad2Deg = 180.0 / kPi; namespace chisel::lang { @@ -77,6 +78,16 @@ Value Interpreter::evaluate(const ExprNode& expr) { else if constexpr (std::is_same_v) { auto it = m_env.find(node.name); if (it != m_env.end()) return it->second; + // Predefined constants/special vars — checked only once the + // environment lookup misses, so a user assignment of the same + // name (e.g. `PI = 3;`) still wins, matching OpenSCAD (these + // aren't protected keywords, just default values). + if (node.name == "PI") return Value::fromNumber(kPi); + if (node.name == "$preview") return Value::fromBool(true); // no separate export-render pass exists yet + if (node.name == "$t") return Value::fromNumber(0.0); // no animation/scrubbing yet (see roadmap v4) + if (node.name == "$parent_modules") + return Value::fromNumber(m_moduleNameStack.empty() ? 0.0 + : static_cast(m_moduleNameStack.size() - 1)); return Value::undef(); } @@ -616,6 +627,104 @@ Value Interpreter::callBuiltin(const std::string& name, return Value::fromNumber(pairs.back().second); } + // ---- Type predicates ---- + if (name == "is_undef") return args.size() >= 1 ? Value::fromBool(args[0].isUndef()) : Value::undef(); + if (name == "is_bool") return args.size() >= 1 ? Value::fromBool(args[0].isBool()) : Value::undef(); + if (name == "is_num") return args.size() >= 1 ? Value::fromBool(args[0].isNumber()) : Value::undef(); + if (name == "is_string") return args.size() >= 1 ? Value::fromBool(args[0].isString()) : Value::undef(); + if (name == "is_list") return args.size() >= 1 ? Value::fromBool(args[0].isVector()) : Value::undef(); + // No first-class function-literal value exists yet (see roadmap v3.5), + // so nothing can ever actually be one — always false rather than absent, + // which is still correct given today's set of possible Value tags. + if (name == "is_function") return args.size() >= 1 ? Value::fromBool(false) : Value::undef(); + + // ---- version() / version_num() ---- + // ChiselCAD isn't OpenSCAD, so there's no real "version" to report; this + // reports a fixed OpenSCAD-compatibility level instead, chosen to match + // the newest feature this interpreter actually supports (general list + // comprehensions/each, from OpenSCAD 2019.05) — real-world .scad files + // commonly gate features with `if (version_num() >= 20190500)`, and this + // makes them pick the modern branch. Don't bump this without confirming + // the corresponding OpenSCAD-version feature is actually supported. + if (name == "version") return Value::fromVec({Value::fromNumber(2019), + Value::fromNumber(5), + Value::fromNumber(0)}); + if (name == "version_num") return Value::fromNumber(20190500); + + // ---- parent_module(idx) — name of the module idx levels up the current + // module-call chain (0 = immediate caller). See m_moduleNameStack. ---- + if (name == "parent_module") { + if (args.size() >= 1 && args[0].isNumber()) { + int idx = static_cast(args[0].asNumber()); + int i = static_cast(m_moduleNameStack.size()) - 2 - idx; + if (idx >= 0 && i >= 0) + return Value::fromString(m_moduleNameStack[static_cast(i)]); + } + return Value::undef(); + } + + // ---- search(match_value, string_or_vector, [num_returns_per_match=1], [index_col_num=0]) ---- + if (name == "search") { + if (args.size() < 2) return Value::undef(); + const Value& target = args[1]; + if (!target.isString() && !target.isVector()) return Value::undef(); + + int numReturns = 1; + if (args.size() >= 3 && args[2].isNumber()) numReturns = static_cast(args[2].asNumber()); + int indexCol = 0; + if (args.size() >= 4 && args[3].isNumber()) indexCol = static_cast(args[3].asNumber()); + + std::size_t targetSize = target.isString() ? target.asString().size() : target.asVec().size(); + auto elementAt = [&](std::size_t i) -> Value { + if (target.isString()) + return Value::fromString(std::string(1, target.asString()[i])); + const Value& e = target.asVec()[i]; + if (e.isVector()) { + if (indexCol == -1) return e; // compare against the whole row + if (indexCol >= 0 && static_cast(indexCol) < e.asVec().size()) + return e.asVec()[static_cast(indexCol)]; + return Value::undef(); + } + return e; + }; + auto searchOne = [&](const Value& needle) -> std::vector { + std::vector matches; + for (std::size_t i = 0; i < targetSize; ++i) { + if (valEqRec(needle, elementAt(i))) { + matches.push_back(Value::fromNumber(static_cast(i))); + if (numReturns > 0 && static_cast(matches.size()) >= numReturns) break; + } + } + return matches; + }; + + const Value& matchVal = args[0]; + // A multi-character string or a vector match_value normally searches + // once per element and nests each element's matches in its own + // sub-vector; a single scalar/character match_value returns its + // matches directly (matching OpenSCAD: search("a","abcdabcd") == + // [0], but search("abc","abcdabcd") == [[0],[1],[2]]). + // + // index_col_num == -1 is the exception: it means "compare match_value + // whole against each entire row", specifically so a *vector* + // match_value can be matched as one unit against table rows rather + // than decomposed per-element — so the per-element nesting above + // must not kick in for it. + if (indexCol != -1 && matchVal.isString() && matchVal.asString().size() > 1) { + std::vector outer; + for (char c : matchVal.asString()) + outer.push_back(Value::fromVec(searchOne(Value::fromString(std::string(1, c))))); + return Value::fromVec(std::move(outer)); + } + if (indexCol != -1 && matchVal.isVector()) { + std::vector outer; + for (const auto& needle : matchVal.asVec()) + outer.push_back(Value::fromVec(searchOne(needle))); + return Value::fromVec(std::move(outer)); + } + return Value::fromVec(searchOne(matchVal)); + } + return Value::undef(); // unknown function } diff --git a/src/lang/Interpreter.h b/src/lang/Interpreter.h index 05a7ee7..d5eced7 100644 --- a/src/lang/Interpreter.h +++ b/src/lang/Interpreter.h @@ -5,6 +5,7 @@ #include #include #include +#include namespace chisel::lang { @@ -55,9 +56,17 @@ class Interpreter { std::unordered_map snapshotEnv() const { return m_env; } void restoreEnv(std::unordered_map env) { m_env = std::move(env); } + // Module-call-name stack backing parent_module()/$parent_modules. Module + // calls are evaluated by CsgEvaluator, not here, so CsgEvaluator pushes/ + // pops around each user-module call (mirroring how it already sets + // $children) rather than this class tracking module calls itself. + void pushModuleName(std::string name) { m_moduleNameStack.push_back(std::move(name)); } + void popModuleName() { m_moduleNameStack.pop_back(); } + private: std::unordered_map m_env; std::unordered_map m_funcDefs; + std::vector m_moduleNameStack; // Guards against unbounded recursion in user-defined functions (e.g. a // missing base case) blowing the native call stack. Silent cap, no diff --git a/tests/test_csg_evaluator.cpp b/tests/test_csg_evaluator.cpp index 8583df2..d09a594 100644 --- a/tests/test_csg_evaluator.cpp +++ b/tests/test_csg_evaluator.cpp @@ -58,6 +58,9 @@ static const CsgProjection& asProjection(const CsgNodePtr& n) { static const CsgResize& asResize(const CsgNodePtr& n) { return std::get(*n); } +static const CsgExtrusion& asExtrusion(const CsgNodePtr& n) { + return std::get(*n); +} // --------------------------------------------------------------------------- // Global params forwarding @@ -854,6 +857,32 @@ TEST_CASE("CsgEval:unbound module parameter is undef, not the caller's/global sa REQUIRE(asLeaf(s.roots[0]).params.at("r") == Approx(0.0)); } +TEST_CASE("CsgEval:parent_module(0) returns the immediate caller's module name", "[csg][v35]") { + auto s = evaluate("module inner() { echo(parent_module(0)); }" + "module outer() { inner(); }" + "outer();"); + REQUIRE(s.echoMessages.size() == 1); + REQUIRE(s.echoMessages[0].find("outer") != std::string::npos); +} + +TEST_CASE("CsgEval:$parent_modules counts the ancestor module chain", "[csg][v35]") { + auto s = evaluate("module inner() { echo($parent_modules); }" + "module mid() { inner(); }" + "module outer() { mid(); }" + "outer();"); + REQUIRE(s.echoMessages.size() == 1); + REQUIRE(s.echoMessages[0].find("2") != std::string::npos); +} + +TEST_CASE("CsgEval:parent_module/$parent_modules at the top of the call chain has no parent", + "[csg][v35]") { + auto s = evaluate("module solo() { echo($parent_modules); echo(parent_module(0)); }" + "solo();"); + REQUIRE(s.echoMessages.size() == 2); + REQUIRE(s.echoMessages[0].find("0") != std::string::npos); + REQUIRE(s.echoMessages[1].find("undef") != std::string::npos); +} + TEST_CASE("CsgEval:children() evaluates caller-authored geometry in the caller's scope, not the " "callee's params", "[csg][bugfix]") { @@ -958,6 +987,14 @@ TEST_CASE("CsgEval:echo does not produce geometry", "[csg][tier-c]") { REQUIRE(s.echoMessages.size() == 1); } +TEST_CASE("CsgEval:echo formats named arguments as name = value", "[csg][v35]") { + auto s = evaluate("echo(x=1, \"hi\", y=2);"); + REQUIRE(s.echoMessages.size() == 1); + REQUIRE(s.echoMessages[0].find("x = 1") != std::string::npos); + REQUIRE(s.echoMessages[0].find("y = 2") != std::string::npos); + REQUIRE(s.echoMessages[0].find("\"hi\"") != std::string::npos); +} + // --------------------------------------------------------------------------- // assert() // --------------------------------------------------------------------------- @@ -1082,6 +1119,35 @@ TEST_CASE("CsgEval:children index out of range returns nothing", "[csg][tier-c]" REQUIRE(s.roots.empty()); } +TEST_CASE("CsgEval:children with a vector of indices evaluates each one", "[csg][v35]") { + auto s = evaluate("module pick() { children([0,2]); }" + "pick() { sphere(r=1); cube([1,1,1]); cylinder(h=1,r=1); }"); + REQUIRE(s.roots.size() == 1); + REQUIRE(std::holds_alternative(*s.roots[0])); + const auto& b = asBool(s.roots[0]); + REQUIRE(b.children.size() == 2); + REQUIRE(asLeaf(b.children[0]).kind == CsgLeaf::Kind::Sphere); + REQUIRE(asLeaf(b.children[1]).kind == CsgLeaf::Kind::Cylinder); +} + +TEST_CASE("CsgEval:children with a range of indices evaluates each index in range", "[csg][v35]") { + auto s = evaluate("module first_two() { children([0:1]); }" + "first_two() { sphere(r=1); cube([1,1,1]); cylinder(h=1,r=1); }"); + REQUIRE(s.roots.size() == 1); + const auto& b = asBool(s.roots[0]); + REQUIRE(b.children.size() == 2); + REQUIRE(asLeaf(b.children[0]).kind == CsgLeaf::Kind::Sphere); + REQUIRE(asLeaf(b.children[1]).kind == CsgLeaf::Kind::Cube); +} + +TEST_CASE("CsgEval:children with a vector of indices skips out-of-range entries", "[csg][v35]") { + auto s = evaluate("module pick() { children([0,99]); }" + "pick() { sphere(r=1); cube([1,1,1]); }"); + REQUIRE(s.roots.size() == 1); + REQUIRE(std::holds_alternative(*s.roots[0])); + REQUIRE(asLeaf(s.roots[0]).kind == CsgLeaf::Kind::Sphere); +} + TEST_CASE("CsgEval:children outside module returns nothing", "[csg][tier-c]") { // children() called at top level — not inside a module body auto s = evaluate("children();"); @@ -1121,6 +1187,22 @@ TEST_CASE("CsgEval:$children zero when no children", "[csg][tier-c]") { REQUIRE(asLeaf(s.roots[0]).kind == CsgLeaf::Kind::Cube); } +// --------------------------------------------------------------------------- +// linear_extrude/rotate_extrude params — MeshEvaluator (which actually +// consumes these to build geometry) isn't part of the test binary (it links +// manifold, which the test target doesn't), so this only verifies the +// params survive parsing + CsgEvaluator's generic pass-through into the +// CsgExtrusion IR node — the half that's actually unit-testable here. +// --------------------------------------------------------------------------- +TEST_CASE("CsgEval:linear_extrude slices param is threaded into the IR", "[csg][v35]") { + auto s = evaluate("linear_extrude(height=10, twist=90, slices=50) square(5);"); + REQUIRE(s.roots.size() == 1); + const auto& ext = asExtrusion(s.roots[0]); + REQUIRE(ext.params.at("slices") == Approx(50.0)); + REQUIRE(ext.params.at("twist") == Approx(90.0)); + REQUIRE(ext.params.at("height") == Approx(10.0)); +} + // --------------------------------------------------------------------------- // Recursive functions (Tier C — confirmed already working via Tier A impl) // --------------------------------------------------------------------------- diff --git a/tests/test_interpreter.cpp b/tests/test_interpreter.cpp index 3881762..a54cc77 100644 --- a/tests/test_interpreter.cpp +++ b/tests/test_interpreter.cpp @@ -755,3 +755,125 @@ TEST_CASE("Interp:nested list comprehensions share a joint element budget, not e REQUIRE(v.isVector()); REQUIRE(v.asVec().size() < 10000); } + +// --------------------------------------------------------------------------- +// v3.5 — PI / $preview / $t +// --------------------------------------------------------------------------- +TEST_CASE("Interp:PI is a predefined constant", "[interp][v35]") { + REQUIRE(evalNum("PI") == Approx(3.14159265358979323846)); + REQUIRE(evalNum("sin(PI / 2 * 180 / PI)") == Approx(1.0)); // sanity: usable in expressions +} + +TEST_CASE("Interp:PI can be shadowed by a user assignment", "[interp][v35]") { + // Matches OpenSCAD: PI is a default, not a protected keyword. + Interpreter interp = loadEnv("PI = 3;"); + REQUIRE(interp.getVar("PI").asNumber() == Approx(3.0)); +} + +TEST_CASE("Interp:$preview and $t have sane defaults", "[interp][v35]") { + REQUIRE(bool(evalVal("$preview")) == true); + REQUIRE(evalNum("$t") == Approx(0.0)); +} + +// --------------------------------------------------------------------------- +// v3.5 — is_undef / is_bool / is_num / is_string / is_list / is_function +// --------------------------------------------------------------------------- +TEST_CASE("Interp:is_undef distinguishes undef from other falsy values", "[interp][v35]") { + REQUIRE(bool(evalVal("is_undef(undef)")) == true); + REQUIRE(bool(evalVal("is_undef(some_never_declared_var)")) == true); + REQUIRE(bool(evalVal("is_undef(0)")) == false); + REQUIRE(bool(evalVal("is_undef(false)")) == false); + REQUIRE(bool(evalVal("is_undef([])")) == false); +} + +TEST_CASE("Interp:is_bool/is_num/is_string/is_list each match only their own type", "[interp][v35]") { + REQUIRE(bool(evalVal("is_bool(true)")) == true); + REQUIRE(bool(evalVal("is_bool(1)")) == false); + REQUIRE(bool(evalVal("is_num(42)")) == true); + REQUIRE(bool(evalVal("is_num(\"42\")")) == false); + REQUIRE(bool(evalVal("is_string(\"hi\")")) == true); + REQUIRE(bool(evalVal("is_string(5)")) == false); + REQUIRE(bool(evalVal("is_list([1,2,3])")) == true); + REQUIRE(bool(evalVal("is_list(\"abc\")")) == false); +} + +TEST_CASE("Interp:is_function is always false (no function-literal values exist yet)", "[interp][v35]") { + REQUIRE(bool(evalVal("is_function(42)")) == false); + REQUIRE(bool(evalVal("is_function(\"f\")")) == false); +} + +// --------------------------------------------------------------------------- +// v3.5 — search() +// --------------------------------------------------------------------------- +TEST_CASE("Interp:search single character returns a flat index vector", "[interp][v35]") { + Value v = evalVal("search(\"a\", \"abcdabcd\")"); + REQUIRE(v.isVector()); + REQUIRE(v.asVec().size() == 1); + REQUIRE(v.asVec()[0].asNumber() == Approx(0.0)); +} + +TEST_CASE("Interp:search with num_returns_per_match=0 returns every match", "[interp][v35]") { + Value v = evalVal("search(\"a\", \"abcdabcd\", 0)"); + REQUIRE(v.isVector()); + REQUIRE(v.asVec().size() == 2); + REQUIRE(v.asVec()[0].asNumber() == Approx(0.0)); + REQUIRE(v.asVec()[1].asNumber() == Approx(4.0)); +} + +TEST_CASE("Interp:search with no match returns an empty vector", "[interp][v35]") { + Value v = evalVal("search(\"e\", \"abcdabcd\")"); + REQUIRE(v.isVector()); + REQUIRE(v.asVec().empty()); +} + +TEST_CASE("Interp:search with a multi-character match_value nests one result per character", "[interp][v35]") { + Value v = evalVal("search(\"abc\", \"abcdabcd\")"); + REQUIRE(v.isVector()); + REQUIRE(v.asVec().size() == 3); + for (const auto& e : v.asVec()) REQUIRE(e.isVector()); + REQUIRE(v.asVec()[0].asVec()[0].asNumber() == Approx(0.0)); // 'a' -> [0] + REQUIRE(v.asVec()[1].asVec()[0].asNumber() == Approx(1.0)); // 'b' -> [1] + REQUIRE(v.asVec()[2].asVec()[0].asNumber() == Approx(2.0)); // 'c' -> [2] +} + +TEST_CASE("Interp:search over a table uses index_col_num to pick the compared column", "[interp][v35]") { + Value v = evalVal("search(3, [[1,\"x\"],[3,\"y\"],[3,\"z\"]], 0, 0)"); + REQUIRE(v.isVector()); + REQUIRE(v.asVec().size() == 2); + REQUIRE(v.asVec()[0].asNumber() == Approx(1.0)); + REQUIRE(v.asVec()[1].asNumber() == Approx(2.0)); +} + +TEST_CASE("Interp:search with index_col_num=-1 compares a vector match_value against the whole row", + "[interp][v35]") { + // PR #72 review follow-up: -1 means "match_value matches the entire row + // vector", not a specific column. It also suppresses the usual + // per-element decomposition of a vector match_value (search() normally + // treats a vector match_value as one needle per element), since here the + // whole vector *is* the needle being compared row-by-row. + Value v = evalVal("search([3,\"y\"], [[1,\"x\"],[3,\"y\"],[3,\"z\"]], 0, -1)"); + REQUIRE(v.isVector()); + REQUIRE(v.asVec().size() == 1); + REQUIRE(v.asVec()[0].asNumber() == Approx(1.0)); +} + +TEST_CASE("Interp:search with a negative index_col_num other than -1 matches nothing", "[interp][v35]") { + REQUIRE(evalVal("search(3, [[1,\"x\"],[3,\"y\"]], 0, -2)").asVec().empty()); +} + +// --------------------------------------------------------------------------- +// v3.5 — version() / version_num() +// --------------------------------------------------------------------------- +TEST_CASE("Interp:version_num reports a fixed OpenSCAD-compatibility level", "[interp][v35]") { + REQUIRE(evalNum("version_num()") == Approx(20190500.0)); + REQUIRE(bool(evalVal("version_num() >= 20190500")) == true); +} + +TEST_CASE("Interp:version returns a [major,minor,patch] vector matching version_num", "[interp][v35]") { + Value v = evalVal("version()"); + REQUIRE(v.isVector()); + REQUIRE(v.asVec().size() == 3); + REQUIRE(v.asVec()[0].asNumber() == Approx(2019.0)); + REQUIRE(v.asVec()[1].asNumber() == Approx(5.0)); + REQUIRE(v.asVec()[2].asNumber() == Approx(0.0)); +}