diff --git a/benchmarks/decs/bench_from_decs_count.das b/benchmarks/decs/bench_from_decs_count.das new file mode 100644 index 0000000000..978d96ea50 --- /dev/null +++ b/benchmarks/decs/bench_from_decs_count.das @@ -0,0 +1,67 @@ +options gen2 +options persistent_heap +options no_unused_block_arguments = false +options no_unused_function_arguments = false + +require dastest/testing_boost +require daslib/decs_boost +require daslib/linq +require daslib/linq_boost + +// Bare-count benchmark. Validates Slice 1 of Approach Z: _fold(from_decs_template(type).count()) +// should splice to the arch.size shortcut — sum of per-archetype arch.size in a single for_each_archetype walk. + +let N = 100000 + +[decs_template(prefix = "bench_")] +struct BenchCountRow { + val : int +} + +def fixture(n : int) { + restart() + create_entities(n) $(eid : EntityId; i : int; var cmp : ComponentMap) { + apply_decs_template(cmp, BenchCountRow(val = i)) + } +} + +// m1: hand-written for_each_archetype + arch.size — the ideal target. +[benchmark] +def from_decs_count_m1_hand(b : B?) { + fixture(N) + b |> run("m1_hand_arch_size/{N}", N) { + var erq : EcsRequest + erq.req |> push("bench_val") + var total = 0 + for_each_archetype(erq) $(arch : Archetype) { + total += arch.size + } + if (total == 0) { + b->failNow() + } + } +} + +// m2: from_decs_template.count() bare — eager bridge baseline (no splice). +[benchmark] +def from_decs_count_m2_eager(b : B?) { + fixture(N) + b |> run("m2_eager_bridge/{N}", N) { + let total = from_decs_template(type).count() + if (total == 0) { + b->failNow() + } + } +} + +// m3: _fold(from_decs_template.count()) — Slice 1 splice target. +[benchmark] +def from_decs_count_m3_fold(b : B?) { + fixture(N) + b |> run("m3_fold_splice/{N}", N) { + let total = _fold(from_decs_template(type).count()) + if (total == 0) { + b->failNow() + } + } +} diff --git a/benchmarks/decs/bench_from_decs_template_sum.das b/benchmarks/decs/bench_from_decs_template_sum.das new file mode 100644 index 0000000000..71cef9dc10 --- /dev/null +++ b/benchmarks/decs/bench_from_decs_template_sum.das @@ -0,0 +1,104 @@ +options gen2 +options persistent_heap +options no_unused_block_arguments = false +options no_unused_function_arguments = false + +require dastest/testing_boost +require daslib/decs_boost +require daslib/linq +require daslib/linq_boost + +// Pattern 3 motivation benchmark: from_decs* + linq chain vs hand-written query. +// Establishes the current (eager-bridge + to_sequence + lambda-per-element) cost +// so the new splice can be measured against a clear baseline + the hand-written +// query ideal. + +let N = 100000 +let THRESHOLD = 50 + +[decs_template(prefix = "bench_")] +struct BenchSumRow { + val : int +} + +def fixture(n : int) { + restart() + create_entities(n) $(eid : EntityId; i : int; var cmp : ComponentMap) { + apply_decs_template(cmp, BenchSumRow(val = (i * 37) % 1000)) + } +} + +// m1: hand-written query — the splice's ideal target (no iterator, no lambda, +// per-element body inlined into the archetype walk). +[benchmark] +def from_decs_sum_m1_query_hand(b : B?) { + fixture(N) + b |> run("m1_query_hand/{N}", N) { + var total = 0 + query() $(bench_val : int) { + if (bench_val > THRESHOLD) { + total += bench_val + } + } + if (total == 0) { + b->failNow() + } + } +} + +// m2: from_decs_template eager bridge (current). +// from_decs_template(type)._where(p)._select(s).sum() → +// eager bridge materializes array>, wraps with to_sequence, +// downstream chain pays lambda-per-element on top of materialization. +[benchmark] +def from_decs_sum_m2_template_eager(b : B?) { + fixture(N) + b |> run("m2_from_decs_template_eager/{N}", N) { + let total = ( + from_decs_template(type) + ._where(_.val > THRESHOLD) + ._select(_.val) + .sum() + ) + if (total == 0) { + b->failNow() + } + } +} + +// m3: from_decs block-form eager bridge (same path as m2 but via the older, +// non-template macro). Same cost class. +[benchmark] +def from_decs_sum_m3_block_eager(b : B?) { + fixture(N) + b |> run("m3_from_decs_block_eager/{N}", N) { + let total = ( + from_decs($(bench_val : int){}) + ._where(_.bench_val > THRESHOLD) + ._select(_.bench_val) + .sum() + ) + if (total == 0) { + b->failNow() + } + } +} + +// m4: from_decs_template + _fold(...) (post-Phase 2/3 splice may or may not +// fire here depending on planner-cascade coverage). Reveals whether the existing +// linq_fold splice handles the from_decs* eager bridge at all on master baseline. +[benchmark] +def from_decs_sum_m4_template_fold(b : B?) { + fixture(N) + b |> run("m4_from_decs_template_fold/{N}", N) { + let total = _fold( + from_decs_template(type) + ._where(_.val > THRESHOLD) + ._select(_.val) + .sum() + ) + if (total == 0) { + b->failNow() + } + } +} diff --git a/daslib/decs_boost.das b/daslib/decs_boost.das index e7846d2eea..16796645b4 100644 --- a/daslib/decs_boost.das +++ b/daslib/decs_boost.das @@ -703,3 +703,89 @@ class FromDecsMacro : AstCallMacro { ) } } + +[call_macro(name="from_decs_template")] +class FromDecsTemplateMacro : AstCallMacro { + //! This macro converts a DECS query over a ``[decs_template]`` struct into an ``iterator>``. + //! The field list (names and types) is derived from the struct definition; component names are + //! the struct's prefix (per ``[decs_template(prefix=...)]``) joined with each field name. For example:: + //! + //! [decs_template(prefix="particle_")] + //! struct Particle { pos, vel : float3 } + //! let it = from_decs_template(type) + //! for (item in it) { + //! print("pos {item.pos}, vel {item.vel}\n") + //! } + //! + //! This is the eager bridge form — equivalent to writing + //! ``from_decs($(particle_pos : float3; particle_vel : float3){})`` by hand and projecting back into + //! field names. Tuple element types are by-value (the query's archetype refs do not outlive the + //! invoke block). + def override visit(prog : ProgramPtr; mod : Module?; var expr : ExprCallMacro?) : ExpressionPtr { + macro_verify(length(expr.arguments) == 1, prog, expr.at, "expecting from_decs_template(type)") + macro_verify(expr.arguments[0] is ExprTypeDecl, prog, expr.at, "expecting from_decs_template(type), got {expr.arguments[0].__rtti}") + let typeExpr = expr.arguments[0] as ExprTypeDecl + let td = typeExpr.typeexpr + macro_verify(td != null && td.baseType == Type.tStructure, prog, expr.at, "from_decs_template expects type, got non-struct type") + let st = td.structType + macro_verify(st != null, prog, expr.at, "from_decs_template: struct type is not resolved") + // Resolve [decs_template] annotation + prefix + var prefix = "" + var hasTemplate = false + for (ann in st.annotations) { + if (ann.annotation.name == "decs_template") { + hasTemplate = true + let ppref = ann.arguments |> decs_prefix + prefix = (ppref is yes) ? (ppref as yes) : "{st.name}_" + break + } + } + macro_verify(hasTemplate, prog, expr.at, "from_decs_template: struct {st.name} is not annotated with [decs_template]") + macro_verify(!empty(st.fields), prog, expr.at, "from_decs_template: struct {st.name} has no fields") + // Tuple type (field names as record names, by-value types stripped of ref/const) + var tupleType = new TypeDecl(baseType = Type.tTuple, at = expr.at) + tupleType.argNames.resize(length(st.fields)) + for (fld, idx in st.fields, count()) { + var ft = clone_type(fld._type) + ft.flags.ref = false + ft.flags.constant = false + tupleType.argTypes |> emplace_new(ft) + tupleType.argNames[idx] := fld.name + } + // Tuple constructor: pull each prefixed component into a record-named field + var mkTuple = new ExprMakeTuple(at = expr.at) + mkTuple.recordNames.resize(length(st.fields)) + for (fld, idx in st.fields, count()) { + mkTuple.values |> push(new ExprVar(at = fld.at, name := "{prefix}{fld.name}")) + mkTuple.recordNames[idx] := fld.name + } + // Query block: one arg per field, name = prefixed component, type = const non-ref + // fld.init (if set) clones in so the query macro picks get_default_ro + var qblock = qmacro(${ + res |> push($e(mkTuple)) + }) + var qba = qblock as ExprMakeBlock + var qbb = qba._block as ExprBlock + qbb.blockFlags.isClosure = true + for (fld in st.fields) { + var argType = clone_type(fld._type) + argType.flags.ref = false + argType.flags.constant = true + qbb.arguments |> emplace_new <| new Variable(at = fld.at, + name := "{prefix}{fld.name}", + _type = argType, + init = clone_expression(fld.init) + ) + } + // Call query macro and wrap in invoke + var callquery = make_call(expr.at, "query") + (callquery as ExprCallMacro).arguments |> push(clone_expression(qblock)) + return qmacro( + invoke(${ + var res : array<$t(tupleType)> + $e(callquery) + return res.to_sequence() + }) + ) + } +} diff --git a/daslib/linq_fold.das b/daslib/linq_fold.das index e12900f935..47ec7f7e72 100644 --- a/daslib/linq_fold.das +++ b/daslib/linq_fold.das @@ -2873,6 +2873,262 @@ def private plan_group_by(var expr : Expression?) : Expression? { return finalize_emission_stmts(top, srcName, at, stmts) } +// ── decs eager-bridge unroll (Approach Z — for_each_archetype + nested _fold) ─────── + +struct private DecsBridgeShape { + reqHashExpr : ExpressionPtr // ExprConstUInt64 — cloned, ready to splice + erqExpr : ExpressionPtr // ExprAddr — cloned, ready to splice + archName : string // bridge's arch param name (reused as-is so cloned sources stay valid) + forExpr : ExpressionPtr // cloned ExprFor — inner multi-iter for-loop; body replaced when splicing + iterNames : array // bridge's iter var names (prefixed component names) + userNames : array // user-facing field names from the push tuple — feed the named-tuple bind that lets the user's `_.userName` chain access work +} + +[macro_function] +def private get_call_short_name(c : ExprCall?) : string { + // Strip module prefix + mangler hash. "__::linq`to_sequence`hash" → "to_sequence". + let s = string(c.name) + let bt = s |> rfind("`") + if (bt >= 0) { + let prev = s |> slice(0, bt) |> rfind("`") + if (prev >= 0) return s |> slice(prev + 1, bt) + } + let cc = s |> rfind("::") + if (cc >= 0) return s |> slice(cc + 2) + return s +} + +[macro_function] +def private extract_decs_bridge(var top : Expression?) : DecsBridgeShape? { + // Pattern-match the post-expansion from_decs*-eager-bridge shape. Mismatch → null cascades to tier-2. + if (!(top is ExprInvoke)) return null + var topInv = top as ExprInvoke + if (topInv.arguments |> length != 1 + || !(topInv.arguments[0] is ExprMakeBlock)) return null + var mblk = topInv.arguments[0] as ExprMakeBlock + if (!(mblk._block is ExprBlock)) return null + var blk = mblk._block as ExprBlock + if (blk.list |> length != 3 + || !empty(blk.arguments) + || !(blk.list[0] is ExprLet) + || !(blk.list[1] is ExprCall) + || !(blk.list[2] is ExprReturn)) return null + // Statement 0: var res : array> + var letStmt = blk.list[0] as ExprLet + if (letStmt.variables |> length != 1) return null + var resVar = letStmt.variables[0] + if (!resVar._type.isGoodArrayType + || resVar._type.firstType.baseType != Type.tTuple) return null + let resVarName = string(resVar.name) + // Statement 1: for_each_archetype(req_hash, erq, $(arch) { for(iter_vars in get_ro_sources){push((userNames=iter_vars))} }) + var feaCall = blk.list[1] as ExprCall + if (feaCall.name != "for_each_archetype" + || feaCall.arguments |> length != 3 + || !(feaCall.arguments[0] is ExprConstUInt64) + || !(feaCall.arguments[1] is ExprAddr) + || !(feaCall.arguments[2] is ExprMakeBlock)) return null + var feaBlkExpr = feaCall.arguments[2] as ExprMakeBlock + if (!(feaBlkExpr._block is ExprBlock)) return null + var feaBlk = feaBlkExpr._block as ExprBlock + if (feaBlk.arguments |> length != 1 + || feaBlk.list |> length != 1 + || !(feaBlk.list[0] is ExprFor)) return null + let archName = string(feaBlk.arguments[0].name) + var forExpr = feaBlk.list[0] as ExprFor + // Body must be ExprBlock with single push call whose tuple recordNames are the user-facing field names. + if (empty(forExpr.sources) + || !(forExpr.body is ExprBlock)) return null + var forBody = forExpr.body as ExprBlock + if (forBody.list |> length != 1 + || !(forBody.list[0] is ExprCall)) return null + var pushCall = forBody.list[0] as ExprCall + // Push target must be the SAME `res` from stmt 0 — guards a user-written invoke that returns res.to_sequence() but pushes elsewhere. + if (get_call_short_name(pushCall) != "push" + || pushCall.arguments |> length != 2 + || !(pushCall.arguments[0] is ExprVar) + || (pushCall.arguments[0] as ExprVar).name != resVarName + || !(pushCall.arguments[1] is ExprMakeTuple)) return null + var mkTup = pushCall.arguments[1] as ExprMakeTuple + if (mkTup.recordNames |> length != forExpr.sources |> length + || forExpr.iteratorVariables |> length != forExpr.sources |> length) return null + var userNames : array + for (n in mkTup.recordNames) { + userNames |> push(string(n)) + } + var iterNames : array + for (v in forExpr.iteratorVariables) { + iterNames |> push(string(v.name)) + } + // Statement 2: return res.to_sequence() — must reference the same `res`. + var retStmt = blk.list[2] as ExprReturn + if (retStmt.subexpr == null || !(retStmt.subexpr is ExprCall)) return null + let retCall = retStmt.subexpr as ExprCall + if (get_call_short_name(retCall) != "to_sequence" + || empty(retCall.arguments) + || !(retCall.arguments[0] is ExprVar) + || (retCall.arguments[0] as ExprVar).name != resVarName) return null + var info = new DecsBridgeShape( + reqHashExpr = clone_expression(feaCall.arguments[0]), + erqExpr = clone_expression(feaCall.arguments[1]), + archName := archName, + forExpr = clone_expression(forExpr), + iterNames <- iterNames, + userNames <- userNames + ) + return info +} + +[macro_function] +def private emit_decs_count_archsize(bridge : DecsBridgeShape?; at : LineInfo) : Expression? { + // Bare count(): no chain ops, sum arch.size per archetype — skips the per-entity walk entirely. + let accName = "`decs_acc`{at.line}`{at.column}" + let archName = "`decs_arch`{at.line}`{at.column}" + var reqExpr = clone_expression(bridge.reqHashExpr) + var erqExpr = clone_expression(bridge.erqExpr) + var emission : Expression? = qmacro(invoke($() : int { + var $i(accName) = 0 + for_each_archetype($e(reqExpr), $e(erqExpr), $($i(archName) : Archetype) { + $i(accName) += $i(archName).size + }) + return $i(accName) + })) + emission.force_at(at) + emission.force_generated(true) + return emission +} + +[macro_function] +def private emit_decs_accumulator(bridge : DecsBridgeShape?; + opName : string; + var projection : Expression?; + var whereCond : Expression?; + var accType : TypeDeclPtr; + at : LineInfo) : Expression? { + // Slice 2 accumulator emission: count / long_count / sum with optional _where + single _select chain ops. + let accName = "`decs_acc`{at.line}`{at.column}" + let tupName = "`decs_tup`{at.line}`{at.column}" + // Per-element body: acc += for sum, acc++ for count/long_count. + var perElement : Expression? + if (opName == "sum") { + perElement = qmacro_expr() { + $i(accName) += $e(projection) + } + } elif (opName == "long_count") { + perElement = qmacro_expr() { + $i(accName) ++ + } + } else { + perElement = qmacro_expr() { + $i(accName) ++ + } + } + // Wrap with where filter. + var body = wrap_with_condition(perElement, whereCond) + // Named-tuple bind: fold_linq_cond(lambda, tupName) rebinds `_.userName` → `tup.userName`, so chain ops see a real named tuple. + var mkTup = new ExprMakeTuple(at = at) + mkTup.recordNames |> resize(length(bridge.userNames)) + for (i in 0 .. length(bridge.userNames)) { + mkTup.recordNames[i] := bridge.userNames[i] + mkTup.values |> emplace_new(new ExprVar(at = at, name := bridge.iterNames[i])) + } + var tupBind : Expression? = qmacro_expr() { + var $i(tupName) = $e(mkTup) + } + var forBodyStmts <- [tupBind, body] + var forBody = stmts_to_expr(forBodyStmts) + // Cloned for retains the bridge's iter vars + get_ro sources (which reference archName); reuse archName below. + var clonedForExpr = clone_expression(bridge.forExpr) + var clonedFor = clonedForExpr as ExprFor + var newForBody = new ExprBlock(at = at) + newForBody.list |> push(forBody) + clonedFor.body = newForBody + let archName = bridge.archName + var reqExpr = clone_expression(bridge.reqHashExpr) + var erqExpr = clone_expression(bridge.erqExpr) + var forExprNode : Expression? = clonedForExpr + var emission : Expression? + if (opName == "long_count") { + emission = qmacro(invoke($() : int64 { + var $i(accName) = 0l + for_each_archetype($e(reqExpr), $e(erqExpr), $($i(archName) : Archetype) { + $e(forExprNode) + }) + return $i(accName) + })) + } elif (opName == "count") { + emission = qmacro(invoke($() : int { + var $i(accName) = 0 + for_each_archetype($e(reqExpr), $e(erqExpr), $($i(archName) : Archetype) { + $e(forExprNode) + }) + return $i(accName) + })) + } elif (opName == "sum") { + emission = qmacro(invoke($() : $t(accType) { + var $i(accName) : $t(accType) = default<$t(accType)> + for_each_archetype($e(reqExpr), $e(erqExpr), $($i(archName) : Archetype) { + $e(forExprNode) + }) + return $i(accName) + })) + } else { + return null + } + emission.force_at(at) + emission.force_generated(true) + return emission +} + +[macro_function] +def private plan_decs_unroll(var expr : Expression?) : Expression? { + var (top, calls) = flatten_linq(expr) + let bridge = extract_decs_bridge(top) + if (bridge == null || empty(calls)) return null + let lastName = calls.back()._1.name + let at = expr.at + // Slice 1: bare count → arch.size shortcut. + if (lastName == "count" && length(calls) == 1) return emit_decs_count_archsize(bridge, at) + // Slice 2: chain-aware count/long_count/sum with _where + single _select. + if (lastName != "count" && lastName != "long_count" && lastName != "sum") return null + let tupName = "`decs_tup`{at.line}`{at.column}" + let intermediateEnd = length(calls) - 1 + var whereCond : Expression? + var projection : Expression? + var seenSelect = false + for (i in 0 .. intermediateEnd) { + var cll & = unsafe(calls[i]) + let opName = cll._1.name + if (opName == "where_") { + // After-select where: defer to follow-up; canonical order is where-then-select. + if (seenSelect) return null + var pred = fold_linq_cond(cll._0.arguments[1], tupName) + if (pred == null) return null + if (whereCond == null) { + whereCond = pred + } else { + whereCond = qmacro($e(whereCond) && $e(pred)) + } + } elif (opName == "select") { + // Chained selects: defer to follow-up. + if (seenSelect) return null + projection = fold_linq_cond(cll._0.arguments[1], tupName) + if (projection == null) return null + seenSelect = true + } else { + return null + } + } + // sum requires a scalar projection (tuple sources can't sum directly). + var accType : TypeDeclPtr + if (lastName == "sum") { + if (projection == null || projection._type == null) return null + accType = clone_type(projection._type) + accType.flags.constant = false + accType.flags.ref = false + } + return emit_decs_accumulator(bridge, lastName, projection, whereCond, accType, at) +} + [macro_function] def private plan_zip(var expr : Expression?) : Expression? { // Phase 2 Z1/Z2/Z3: 2-ary lockstep zip splice. Supports bare zip (array/iterator), no-pred count/long_count, and fused where_/select/take/skip/take_while/skip_while chain ops between zip and terminator. Result-selector form (3-arg zip), accumulator terminators (sum/min/max/etc.), and chained selects bail to tier-2 cascade. @@ -3146,6 +3402,8 @@ class private LinqFold : AstCallMacro { if (res != null) return res res = plan_group_by(call.arguments[0]) if (res != null) return res + res = plan_decs_unroll(call.arguments[0]) + if (res != null) return res res = plan_zip(call.arguments[0]) if (res != null) return res res = plan_loop_or_count(call.arguments[0]) diff --git a/site/blog/_posts/dejavu-all-over-again.md b/site/blog/_posts/dejavu-all-over-again.md new file mode 100644 index 0000000000..d4d4afe7be --- /dev/null +++ b/site/blog/_posts/dejavu-all-over-again.md @@ -0,0 +1,33 @@ +--- +title: Its like dejavu all over again +date: 2026-05-19 19:48:17 +tags: + - daScript + - C++ +--- + +I feel like I've done this before. It brings back memories. + + + +Back in 1812 we went from Borland C++ 3.1 to 4.0. It had 32 bit support via DPMI. I had 32 bit PC. We later switched to Watcom C++ with its DOS/4GW. Fun times (ask your AI of choice to explain). + +Part of the fun was porting codebase to 32 bit. It mostly just worked, somehow. Not that it was that much code. Not that it had any test coverage worth mentioning. There were few containers, some pointer math here and there, but overall smooth sailing. Took like half a day. + +Its 2026 and I'm switching das to use 64 bit arrays and tables. I could have made that choice 7 years ago. I seriously thought about it. It was an embedded game language at the time. Since then we've hit this limitation once - due to a bug in a serializer. Its just not that important, as long as overall memory pool is not limited to 64 bits - limiting tables and even arrays is not an issue. + +Until u want to play with inference that is. Big models, big matrices, fast idiomatic code. I'm pretty sure I can get there without intrinsics - quantum macros advantage with LLVM fast path is something else. You just wait and see. CPU first. Then maybe Vulkan, and sadly CUDA. + +But to even attempt something like this, we need 64 bit arrays. So here I am with my trusty (but verifyish) companion looking at 8.5k+ tests, couple dozen systems, and 7 years of bad habits. It'll hit 9k before we're done. + +It could not have been size_t - it had to be uint32_t - otherwise bindings diverge. 32-bit version will suffer. It'll likely choke. 32-bit MSVC hardly works. 32-bit CLANG has its moments. It had one thing going for it - FP exceptions, but today I would not even bother. + +Do you also measure new features in tests? Do you measure your technical debt in tests? Or do you scream AI slope?? Its turtles all the way down that rabbit hole. + +As a result I have time to write a blog post, while mechanical search-and-rewrite is searching for int32_t/uint32_t/int where the data is, adding tests every time one is removed, replaced, or 'preserved'. + +There will be long_length next to length, long_count next to count, long_find_index, and some other goofy stuff which u can find in legacy systems. I am writing a legacy system after all - lisp+fortran in disguise of a modern language syntax. + +Imagine breaking 8.5k tests, several released titles, and few more 'in production' just to play with inference and loop optimizations... tempting. Thats how u end up with long_reserve and 2 sets of array index nodes with independent fusion. + +It is AI friendly though; when its das. When its C++ part - its blog time. diff --git a/site/playground/index.html b/site/playground/index.html index 8554b6d614..99d0ec581c 100644 --- a/site/playground/index.html +++ b/site/playground/index.html @@ -116,7 +116,10 @@

Open this on a laptop.

- + +
+
+
diff --git a/site/playground/playground-tabs.js b/site/playground/playground-tabs.js index 38ea8a6786..7006bb652c 100644 --- a/site/playground/playground-tabs.js +++ b/site/playground/playground-tabs.js @@ -49,6 +49,13 @@ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(getStateJson())); } catch (e) { /* quota / disabled — ignore */ } + // Refresh the Test button's enabled state on the same debounced + // tick — covers edit / switch / add / rename / delete via one + // signal. Inside the setTimeout so rapid keystrokes coalesce + // into one regex scan instead of one per keystroke. + if (typeof window.updateTestButtonState === 'function') { + window.updateTestButtonState(); + } }, AUTOSAVE_DEBOUNCE_MS); } diff --git a/site/tests/playground/test-button.spec.js b/site/tests/playground/test-button.spec.js new file mode 100644 index 0000000000..d21e47e091 --- /dev/null +++ b/site/tests/playground/test-button.spec.js @@ -0,0 +1,63 @@ +// Test button: write a file with [test] functions, hit ▶ test, check dastest +// output. Requires WASM (tagged @wasm so the no-WASM CI gate skips this file). + +const { test, expect } = require('./fixtures.js'); + +async function waitTabsReady(page) { + await page.waitForFunction(() => !!window.pgState, null, { timeout: 10_000 }); + await page.locator('#pg-tabs .pg-tab[data-file="main.das"]').waitFor(); +} + +async function waitWasmReady(page) { + await page.waitForFunction( + () => typeof window.FS !== 'undefined' && typeof window.Module?.callMain === 'function', + null, + { timeout: 30_000 } + ); +} + +test('runs [test] functions and reports SUCCESS @wasm', async ({ playground }) => { + await waitTabsReady(playground); + await waitWasmReady(playground); + + await playground.evaluate(() => { + window.pgSwitchFile('main.das'); + window.code.getDoc().setValue([ + 'options gen2', + 'require dastest/testing_boost public', + '', + '[test]', + 'def test_math(t : T?) {', + ' t |> equal(2 + 2, 4)', + '}', + '', + ].join('\n')); + }); + + await playground.locator('#test').click(); + await expect(playground.locator('.output_line_text', { hasText: 'SUCCESS!' })) + .toBeVisible({ timeout: 30_000 }); +}); + +test('reports FAILED when a [test] assertion fails @wasm', async ({ playground }) => { + await waitTabsReady(playground); + await waitWasmReady(playground); + + await playground.evaluate(() => { + window.pgSwitchFile('main.das'); + window.code.getDoc().setValue([ + 'options gen2', + 'require dastest/testing_boost public', + '', + '[test]', + 'def test_fails(t : T?) {', + ' t |> equal(1, 2)', + '}', + '', + ].join('\n')); + }); + + await playground.locator('#test').click(); + await expect(playground.locator('.output_line_text', { hasText: 'FAILED!' })) + .toBeVisible({ timeout: 30_000 }); +}); diff --git a/tests/linq/test_linq_from_decs.das b/tests/linq/test_linq_from_decs.das index eaed419066..46fdc7aab9 100644 --- a/tests/linq/test_linq_from_decs.das +++ b/tests/linq/test_linq_from_decs.das @@ -1,10 +1,17 @@ options gen2 +options rtti require daslib/linq require dastest/testing_boost public require math +require strings require daslib/linq_boost require daslib/decs_boost +require daslib/ast +require daslib/rtti +require daslib/ast_boost +require daslib/ast_match +require daslib/templates_boost [test] def test_from_decs(t : T?) { @@ -34,3 +41,283 @@ def test_from_decs(t : T?) { } } } + +[decs_template(prefix = "ftd_default_")] +struct FromTemplateDefault { + index : int + text : string +} + +[decs_template] +struct FromTemplateImplicit { + iv : int + sv : string +} + +[decs_template(prefix)] +struct FromTemplateEmpty { + ev_index : int + ev_text : string +} + +[test] +def test_from_decs_template(t : T?) { + restart() + for (i in range(3)) { + create_entity() @(eid, cmp) { + cmp.eid := eid + cmp.ftd_default_index := i + cmp.ftd_default_text := "ftd{i}" + cmp.FromTemplateImplicit_iv := i * 10 + cmp.FromTemplateImplicit_sv := "imp{i}" + cmp.ev_index := i * 100 + cmp.ev_text := "emp{i}" + } + } + commit() + t |> run("from_decs_template custom prefix parity") @(tt : T?) { + var via_template = ( + from_decs_template(type) + ._where(_.index < 2) + .reverse() + .to_array() + ) + var via_explicit = ( + from_decs($(ftd_default_index : int; ftd_default_text : string){}) + ._where(_.ftd_default_index < 2) + .reverse() + .to_array() + ) + tt |> equal(via_template.length(), via_explicit.length()) + tt |> equal(via_template.length(), 2) + for (a, b in via_template, via_explicit) { + tt |> equal(a.index, b.ftd_default_index) + tt |> equal(a.text, b.ftd_default_text) + } + } + t |> run("from_decs_template default-prefix parity (struct name)") @(tt : T?) { + let via_template = ( + from_decs_template(type)._select(_.iv).sum() + ) + let via_explicit = ( + from_decs($(FromTemplateImplicit_iv : int; FromTemplateImplicit_sv : string){})._select(_.FromTemplateImplicit_iv).sum() + ) + tt |> equal(via_template, via_explicit) + tt |> equal(via_template, 0 + 10 + 20) + } + t |> run("from_decs_template empty-prefix parity") @(tt : T?) { + let via_template = ( + from_decs_template(type).count() + ) + let via_explicit = ( + from_decs($(ev_index : int; ev_text : string){}).count() + ) + tt |> equal(via_template, via_explicit) + tt |> equal(via_template, 3) + } + t |> run("from_decs_template projection by field name") @(tt : T?) { + let sum_idx = ( + from_decs_template(type)._select(_.index).sum() + ) + tt |> equal(sum_idx, 0 + 1 + 2) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Approach Z splice (plan_decs_unroll) — Slice 1: bare count via arch.size +// ───────────────────────────────────────────────────────────────────────────── + +[decs_template(prefix = "unroll_")] +struct UnrollCountRow { + val : int +} + +def private describe_count(expr : Expression?; needle : string) : int { + if (expr == null) return 0 + let s = describe(expr) + var n = 0 + var i = 0 + let nl = length(needle) + while (true) { + let p = s |> find(needle, i) + if (p < 0) break + n ++ + i = p + nl + } + return n +} + +// Structural ExprFor counter — robust against describe() formatting changes (Copilot review #2). +def private count_expr_for(expr : Expression?) : int { + if (expr == null) return 0 + var n = 0 + if (expr is ExprFor) { + n ++ + var ef = expr as ExprFor + for (s in ef.sources) { + n += count_expr_for(s) + } + n += count_expr_for(ef.body) + } elif (expr is ExprBlock) { + var eb = expr as ExprBlock + for (s in eb.list) { + n += count_expr_for(s) + } + } elif (expr is ExprMakeBlock) { + var emb = expr as ExprMakeBlock + n += count_expr_for(emb._block) + } elif (expr is ExprInvoke) { + var ei = expr as ExprInvoke + for (a in ei.arguments) { + n += count_expr_for(a) + } + } elif (expr is ExprCall) { + var ec = expr as ExprCall + for (a in ec.arguments) { + n += count_expr_for(a) + } + } elif (expr is ExprLet) { + var el = expr as ExprLet + for (v in el.variables) { + n += count_expr_for(v.init) + } + } elif (expr is ExprReturn) { + var er = expr as ExprReturn + n += count_expr_for(er.subexpr) + } elif (expr is ExprIfThenElse) { + var ei = expr as ExprIfThenElse + n += count_expr_for(ei.cond) + n += count_expr_for(ei.if_true) + n += count_expr_for(ei.if_false) + } + return n +} + +[export, marker(no_coverage)] +def target_unroll_count_fold() : int { + return _fold(from_decs_template(type).count()) +} + +[test] +def test_unroll_count_parity(t : T?) { + restart() + for (i in range(7)) { + create_entity() @(eid, cmp) { + cmp.eid := eid + cmp.unroll_val := i + } + } + commit() + t |> equal(target_unroll_count_fold(), 7, "Slice 1 count splice via arch.size") +} + +[test] +def test_unroll_count_empty(t : T?) { + restart() + commit() + t |> equal(target_unroll_count_fold(), 0, "empty archetypes count to 0") +} + +[test] +def test_unroll_count_splice_shape(t : T?) { + ast_gc_guard() { + var func = find_module_function_via_rtti(compiling_module(), @@target_unroll_count_fold) + t |> success(func != null, "RTTI must resolve target_unroll_count_fold") + if (func == null) return + var body_expr : ExpressionPtr + let r = qmatch_function(func) $() { + return <- $e(body_expr) + } + t |> success(r.matched && body_expr is ExprInvoke, "expected splice invoke wrapper") + // arch.size shortcut: no inner for-loop (per-entity walk), no to_sequence. + t |> equal(describe_count(body_expr, "to_sequence"), 0, "count splice must NOT call to_sequence") + t |> equal(describe_count(body_expr, "for_each_archetype"), 1, "count splice emits exactly one for_each_archetype") + t |> equal(count_expr_for(body_expr), 0, "count splice with no chain ops must NOT emit inner per-entity for-loop") + t |> equal(describe_count(body_expr, ".size"), 1, "count splice reads arch.size") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Slice 2: chain-aware count/long_count/sum via for_each_archetype + named-tuple bind +// ───────────────────────────────────────────────────────────────────────────── + +[decs_template(prefix = "unroll2_")] +struct UnrollChainRow { + val : int + flag : int +} + +def private fixture_unroll2(n : int) { + restart() + for (i in range(n)) { + create_entity() @(eid, cmp) { + cmp.eid := eid + cmp.unroll2_val := i + cmp.unroll2_flag := i % 2 + } + } + commit() +} + +[export, marker(no_coverage)] +def target_unroll_select_sum_fold() : int { + return _fold(from_decs_template(type)._select(_.val).sum()) +} + +[export, marker(no_coverage)] +def target_unroll_where_count_fold() : int { + return _fold(from_decs_template(type)._where(_.flag == 1).count()) +} + +[export, marker(no_coverage)] +def target_unroll_where_select_sum_fold() : int { + return _fold(from_decs_template(type)._where(_.flag == 1)._select(_.val).sum()) +} + +[export, marker(no_coverage)] +def target_unroll_long_count_fold() : int64 { + return _fold(from_decs_template(type).long_count()) +} + +[test] +def test_unroll_select_sum_parity(t : T?) { + fixture_unroll2(5) + // 0+1+2+3+4 = 10 + t |> equal(target_unroll_select_sum_fold(), 10, "select+sum splice parity") +} + +[test] +def test_unroll_where_count_parity(t : T?) { + fixture_unroll2(5) + // flags: 0,1,0,1,0 → flag==1 hits 2 rows + t |> equal(target_unroll_where_count_fold(), 2, "where+count splice parity") +} + +[test] +def test_unroll_where_select_sum_parity(t : T?) { + fixture_unroll2(5) + // flag==1 rows: vals 1,3 → sum 4 + t |> equal(target_unroll_where_select_sum_fold(), 4, "where+select+sum splice parity") +} + +[test] +def test_unroll_long_count_parity(t : T?) { + fixture_unroll2(7) + t |> equal(target_unroll_long_count_fold(), 7l, "long_count splice parity") +} + +[test] +def test_unroll_select_sum_splice_shape(t : T?) { + ast_gc_guard() { + var func = find_module_function_via_rtti(compiling_module(), @@target_unroll_select_sum_fold) + t |> success(func != null, "RTTI must resolve target_unroll_select_sum_fold") + if (func == null) return + var body_expr : ExpressionPtr + let r = qmatch_function(func) $() { + return <- $e(body_expr) + } + t |> success(r.matched && body_expr is ExprInvoke, "expected splice invoke wrapper") + t |> equal(describe_count(body_expr, "to_sequence"), 0, "select+sum splice must NOT call to_sequence") + t |> equal(describe_count(body_expr, "for_each_archetype"), 1, "exactly one for_each_archetype") + } +} diff --git a/web/CMakeLists.txt b/web/CMakeLists.txt index b606531cc8..56fd906c6c 100644 --- a/web/CMakeLists.txt +++ b/web/CMakeLists.txt @@ -40,7 +40,20 @@ add_link_options( -sALLOW_MEMORY_GROWTH=1 ) # embed daslang standard library into wasm binary. -add_link_options(--embed-file ${CMAKE_CURRENT_SOURCE_DIR}/../daslib@daslib) +# SHELL: keeps the --embed-file + path pair atomic — without it, CMake folds +# repeated --embed-file flags into one token (drops the second), so the second +# directory becomes a positional arg to em++ and the embed silently fails. +add_link_options("SHELL:--embed-file ${CMAKE_CURRENT_SOURCE_DIR}/../daslib@daslib") +# Exclude modules whose native dependencies are not linked in the WASM build. +# spoof + its only consumer linked_list both require peg/peg (dasPEG module), +# which is not linked here. Embedding them gives users a confusing +# "module peg/peg not found" compile error; better to fail at the require +# itself with "module not found". +add_link_options("SHELL:--exclude-file */daslib/spoof.das") +add_link_options("SHELL:--exclude-file */daslib/linked_list.das") +# embed dastest runner so the playground's Test button can invoke +# /dastest/dastest.das against user-authored [test] functions. +add_link_options("SHELL:--embed-file ${CMAKE_CURRENT_SOURCE_DIR}/../dastest@dastest") IF(DEFINED DAS_CONFIG_INCLUDE_DIR) INCLUDE_DIRECTORIES(${DAS_CONFIG_INCLUDE_DIR}) diff --git a/web/ui/samples/data.json b/web/ui/samples/data.json index 5959ca423f..cdb5212572 100644 --- a/web/ui/samples/data.json +++ b/web/ui/samples/data.json @@ -20,6 +20,10 @@ { "name" : "Random Sequence", "files" : ["examples/random_sequence.das"] + }, + { + "name" : "Tests", + "files" : ["examples/tests.das"] } ] } diff --git a/web/ui/samples/examples/tests.das b/web/ui/samples/examples/tests.das new file mode 100644 index 0000000000..4da1d7166d --- /dev/null +++ b/web/ui/samples/examples/tests.das @@ -0,0 +1,24 @@ +options gen2 + +require dastest/testing_boost public +require strings + +[test] +def test_adds(t : T?) { + t |> equal(2 + 2, 4) + t |> equal("hello, " + "world", "hello, world") +} + +[test] +def test_subtests(t : T?) { + t |> run("strings") <| @(t : T?) { + t |> equal(length("daslang"), 7) + } + t |> run("range") <| @(t : T?) { + var sum = 0 + for (i in range(10)) { + sum += i + } + t |> equal(sum, 45) + } +} diff --git a/web/ui/src/main.js b/web/ui/src/main.js index 3aad107977..496c2438c4 100644 --- a/web/ui/src/main.js +++ b/web/ui/src/main.js @@ -121,21 +121,70 @@ function syncUrlToState() { if (url && url !== location.href) history.pushState(null, '', url); } -runCode = function() { - // Multi-file: sync MEMFS with the current pgState (unlink stale, write - // current), then run main.das. Falls back to the single-buffer path when - // pgState isn't up yet. - if (window.pgState && typeof FS !== 'undefined') { - const current = new Set(Object.keys(window.pgState.files)); - for (const stale of __lastWrittenFiles) { - if (!current.has(stale)) { - try { FS.unlink(stale); } catch (e) { /* ENOENT — ignore */ } - } +// Multi-file MEMFS sync: unlink stale, write current pgState files. Returns +// true when both prerequisites are ready (pgState mounted AND Emscripten's +// FS is up); false when either is still pending. Callers that fall back to +// the single-buffer path must check WASM readiness themselves before touching +// FS — see isWasmReady() and the runCode/runTests guards below. +function syncMemFsFromState() { + if (!window.pgState || typeof FS === 'undefined') return false; + const current = new Set(Object.keys(window.pgState.files)); + for (const stale of __lastWrittenFiles) { + if (!current.has(stale)) { + try { FS.unlink(stale); } catch (e) { /* ENOENT — ignore */ } } - for (const [name, doc] of Object.entries(window.pgState.files)) { - FS.writeFile(name, doc.getValue()); + } + for (const [name, doc] of Object.entries(window.pgState.files)) { + FS.writeFile(name, doc.getValue()); + } + __lastWrittenFiles = current; + return true; +} + +// WASM readiness: callMain + FS both exposed by the Emscripten module. False +// during the ~3-5s page-load window while daslang_static.{js,wasm} streams in, +// or indefinitely if the load fails. Both Run and Test buttons stay disabled +// until this flips to true (see updateButtonStates + Module.onRuntimeInitialized). +function isWasmReady() { + return typeof FS !== 'undefined' + && typeof Module === 'object' && Module !== null + && typeof Module.callMain === 'function'; +} + +// Toggle Run + Test buttons. Run requires WASM ready; Test additionally +// requires a [test] annotation in any open buffer. Called from autosave +// (every buffer/state mutation) and from Module.onRuntimeInitialized (WASM +// finishes loading) so both signals refresh the gating. +var __TEST_ANNOT_RE = /\[\s*test\b/m; +function hasTestAnnotation() { + if (window.pgState) { + for (const doc of Object.values(window.pgState.files)) { + if (__TEST_ANNOT_RE.test(doc.getValue())) return true; } - __lastWrittenFiles = current; + return false; + } + if (typeof code === 'object' && code) { + return __TEST_ANNOT_RE.test(code.getValue()); + } + return false; +} +function updateButtonStates() { + const ready = isWasmReady(); + const runBtn = document.getElementById('run'); + const testBtn = document.getElementById('test'); + if (runBtn) runBtn.disabled = !ready; + if (testBtn) testBtn.disabled = !ready || !hasTestAnnotation(); +} +// Kept under the old name so playground-tabs.js's existing autosave hook +// still works without churn — it triggers a full refresh. +window.updateTestButtonState = updateButtonStates; + +runCode = function() { + if (!isWasmReady()) { + printOutput('daslang is still loading, please wait…', '#ff9393'); + return; + } + if (syncMemFsFromState()) { syncUrlToState(); Module.callMain(['main.das']); return; @@ -144,6 +193,24 @@ runCode = function() { runScript(code.getValue()); } +// Invoke dastest against the current main.das. `[test]` functions in the file +// are discovered + run by dastest/suite.das; the existing Module.print hook +// routes stdout into the output panel. No --color: Emscripten has no TERM and +// our printOutput doesn't render ANSI escapes. --timeout=0 disables dastest's +// wall-clock thread (suite.das wraps each file in new_thread when timeout>0), +// keeping the run single-threaded in the WASM build. +runTests = function() { + if (!isWasmReady()) { + printOutput('daslang is still loading, please wait…', '#ff9393'); + return; + } + if (!syncMemFsFromState()) { + FS.writeFile('main.das', code.getValue()); + } + syncUrlToState(); + Module.callMain(['/dastest/dastest.das', '--', '--test', '/main.das', '--timeout=0']); +} + clearOutput = function() { while (editorOutput.firstChild) { @@ -222,6 +289,12 @@ var Module = { printOutput(text,'#ffffff'); }; })(), + // Re-enable the Run / Test buttons once Emscripten has finished + // loading daslang_static.wasm and exposing FS + callMain. Both buttons + // ship disabled in index.html to avoid the early-click ReferenceError. + onRuntimeInitialized: function() { + if (typeof updateButtonStates === 'function') updateButtonStates(); + }, } window.onerror = function(message)