feat: *= /= operators + 6 new array methods

New assignment operators (full pipeline: lexer → parser → analyzer → codegen):
- *= (MulAssign): count *= 2
- /= (DivAssign): count /= 2

New array methods in event handler (all flush signal + stream diff):
- .clear()   → items = []
- .insert(i, v) → splice at index
- .sort()    → immutable sort
- .reverse() → immutable reverse
- .filter(fn) → filter in place
- .map(fn)   → map in place

New example: language-features.ds (65KB)
- Tests all assignment ops (+=, -=, *=, /=)
- Tests array methods (push, pop, sort, reverse, clear)
- Tests match expressions
- Tests comparison operators
- Tests derived signals (let doubled = count * 2)
- Browser-verified: zero console errors

All 11 examples pass.
This commit is contained in:
enzotar 2026-02-26 20:29:35 -08:00
parent 0125c6e714
commit f4e5ace37c
6 changed files with 158 additions and 0 deletions

View file

@ -48,6 +48,8 @@ pub enum MutationOp {
Set(String), // expression source
AddAssign(String),
SubAssign(String),
MulAssign(String),
DivAssign(String),
}
/// A dependency edge in the signal graph.
@ -360,6 +362,8 @@ fn extract_mutations(expr: &Expr) -> Vec<Mutation> {
ds_parser::AssignOp::Set => MutationOp::Set(format!("{value:?}")),
ds_parser::AssignOp::AddAssign => MutationOp::AddAssign(format!("{value:?}")),
ds_parser::AssignOp::SubAssign => MutationOp::SubAssign(format!("{value:?}")),
ds_parser::AssignOp::MulAssign => MutationOp::MulAssign(format!("{value:?}")),
ds_parser::AssignOp::DivAssign => MutationOp::DivAssign(format!("{value:?}")),
};
mutations.push(Mutation { target: name.clone(), op: mutation_op });
}

View file

@ -1260,6 +1260,8 @@ impl JsEmitter {
AssignOp::Set => format!("{name}.value = {value_js}"),
AssignOp::AddAssign => format!("{name}.value += {value_js}"),
AssignOp::SubAssign => format!("{name}.value -= {value_js}"),
AssignOp::MulAssign => format!("{name}.value *= {value_js}"),
AssignOp::DivAssign => format!("{name}.value /= {value_js}"),
};
(a, name.clone())
}
@ -1270,6 +1272,8 @@ impl JsEmitter {
AssignOp::Set => format!("{target_str} = {value_js}"),
AssignOp::AddAssign => format!("{target_str} += {value_js}"),
AssignOp::SubAssign => format!("{target_str} -= {value_js}"),
AssignOp::MulAssign => format!("{target_str} *= {value_js}"),
AssignOp::DivAssign => format!("{target_str} /= {value_js}"),
};
(a, base_str)
}
@ -1285,6 +1289,8 @@ impl JsEmitter {
AssignOp::Set => format!("{target_str} = {value_js}"),
AssignOp::AddAssign => format!("{target_str} += {value_js}"),
AssignOp::SubAssign => format!("{target_str} -= {value_js}"),
AssignOp::MulAssign => format!("{target_str} *= {value_js}"),
AssignOp::DivAssign => format!("{target_str} /= {value_js}"),
};
(a, root)
}
@ -1294,6 +1300,8 @@ impl JsEmitter {
AssignOp::Set => format!("{s} = {value_js}"),
AssignOp::AddAssign => format!("{s} += {value_js}"),
AssignOp::SubAssign => format!("{s} -= {value_js}"),
AssignOp::MulAssign => format!("{s} *= {value_js}"),
AssignOp::DivAssign => format!("{s} /= {value_js}"),
};
(a, s)
}
@ -1345,6 +1353,52 @@ impl JsEmitter {
sig = signal_name
)
}
"clear" => {
// items.clear() → items.value = []
format!(
"{sig}.value = []; DS._streamDiff(\"{sig}\", {sig}.value)",
sig = signal_name
)
}
"insert" => {
// items.insert(idx, val) → splice
let idx = args_js.first().map(|s| s.as_str()).unwrap_or("0");
let val = args_js.get(1).map(|s| s.as_str()).unwrap_or("undefined");
format!(
"{{ const _a = [...{sig}.value]; _a.splice({idx}, 0, {val}); {sig}.value = _a; DS._streamDiff(\"{sig}\", {sig}.value) }}",
sig = signal_name, idx = idx, val = val
)
}
"sort" => {
// items.sort() → sort a copy
format!(
"{sig}.value = [...{sig}.value].sort(); DS._streamDiff(\"{sig}\", {sig}.value)",
sig = signal_name
)
}
"reverse" => {
// items.reverse() → reverse a copy
format!(
"{sig}.value = [...{sig}.value].reverse(); DS._streamDiff(\"{sig}\", {sig}.value)",
sig = signal_name
)
}
"filter" => {
// items.filter(fn) → filter in place
let fn_js = args_js.first().map(|s| s.as_str()).unwrap_or("() => true");
format!(
"{sig}.value = {sig}.value.filter({fn_js}); DS._streamDiff(\"{sig}\", {sig}.value)",
sig = signal_name, fn_js = fn_js
)
}
"map" => {
// items.map(fn) → map in place
let fn_js = args_js.first().map(|s| s.as_str()).unwrap_or("x => x");
format!(
"{sig}.value = {sig}.value.map({fn_js}); DS._streamDiff(\"{sig}\", {sig}.value)",
sig = signal_name, fn_js = fn_js
)
}
_ => {
// Generic method call: obj.method(args)
format!("{}.{}({})", obj_js, method, args_js.join(", "))

View file

@ -353,6 +353,8 @@ pub enum AssignOp {
Set,
AddAssign,
SubAssign,
MulAssign,
DivAssign,
}
/// A UI element: `text label`, `button "+" { click: handler }`

View file

@ -81,6 +81,8 @@ pub enum TokenKind {
Not, // !
PlusEq, // +=
MinusEq, // -=
StarEq, // *=
SlashEq, // /=
Arrow, // ->
Semicolon, // ;
Pipe, // |
@ -215,8 +217,10 @@ impl Lexer {
'|' if self.peek_next() == '|' => { self.advance(); self.advance(); Token { kind: TokenKind::Or, lexeme: "||".into(), line, col } }
'+' => { self.advance(); Token { kind: TokenKind::Plus, lexeme: "+".into(), line, col } }
'-' => { self.advance(); Token { kind: TokenKind::Minus, lexeme: "-".into(), line, col } }
'*' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::StarEq, lexeme: "*=".into(), line, col } }
'*' => { self.advance(); Token { kind: TokenKind::Star, lexeme: "*".into(), line, col } }
'/' if self.peek_next() == '/' => self.lex_comment(),
'/' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::SlashEq, lexeme: "/=".into(), line, col } }
'/' => { self.advance(); Token { kind: TokenKind::Slash, lexeme: "/".into(), line, col } }
'%' => { self.advance(); Token { kind: TokenKind::Percent, lexeme: "%".into(), line, col } }
'=' => { self.advance(); Token { kind: TokenKind::Eq, lexeme: "=".into(), line, col } }

View file

@ -751,6 +751,16 @@ impl Parser {
let value = self.parse_expr()?;
Ok(Expr::Assign(Box::new(expr), AssignOp::SubAssign, Box::new(value)))
}
TokenKind::StarEq => {
self.advance();
let value = self.parse_expr()?;
Ok(Expr::Assign(Box::new(expr), AssignOp::MulAssign, Box::new(value)))
}
TokenKind::SlashEq => {
self.advance();
let value = self.parse_expr()?;
Ok(Expr::Assign(Box::new(expr), AssignOp::DivAssign, Box::new(value)))
}
_ => Ok(expr),
}
}

View file

@ -0,0 +1,84 @@
-- DreamStack Language Features Test
-- Exercises all core language features in one place
-- Tests: operators, assignment ops, array methods, computed signals,
-- when/else, match, each, method calls, string interpolation
import { Card } from "../registry/components/card"
import { Badge } from "../registry/components/badge"
import { Button } from "../registry/components/button"
-- State
let count = 10
let items = ["Alpha", "Beta", "Gamma"]
let loggedIn = false
let mode = "normal"
-- Computed signals (derived)
let doubled = count * 2
let isHigh = count > 50
view test = column [
text "🧪 Language Features" { variant: "title" }
text "Testing all operators and methods" { variant: "subtitle" }
-- 1. All assignment operators: +=, -=, *=, /=
Card { title: "Assignment Operators", subtitle: "+= -= *= /=" } [
text "count: {count} | doubled: {doubled}" { variant: "title" }
row [
Button { label: "+5", variant: "primary", onClick: count += 5 }
Button { label: "-3", variant: "secondary", onClick: count -= 3 }
Button { label: "×2", variant: "primary", onClick: count *= 2 }
Button { label: "÷2", variant: "secondary", onClick: count /= 2 }
Button { label: "=1", variant: "ghost", onClick: count = 1 }
]
]
-- 2. Boolean operators && || !
Card { title: "Boolean Logic", subtitle: "&& || ! operators" } [
Button { label: "Toggle Login", variant: "primary", onClick: loggedIn = !loggedIn }
when loggedIn ->
Badge { label: "Logged In ✓", variant: "success" }
else ->
Badge { label: "Logged Out", variant: "error" }
]
-- 3. Array methods: push, pop, clear, sort, reverse
Card { title: "Array Methods", subtitle: "push pop clear sort reverse" } [
text "Items: {items}" { variant: "title" }
text "{items}" { variant: "subtitle" }
row [
Button { label: "Push", variant: "primary", onClick: items.push("New") }
Button { label: "Pop", variant: "secondary", onClick: items.pop() }
Button { label: "Sort", variant: "ghost", onClick: items.sort() }
Button { label: "Reverse", variant: "ghost", onClick: items.reverse() }
Button { label: "Clear", variant: "destructive", onClick: items.clear() }
]
each item in items ->
row [
text "→"
text item
]
]
-- 4. Match expressions
Card { title: "Pattern Matching", subtitle: "match with fall-through" } [
row [
Button { label: "Normal", variant: "secondary", onClick: mode = "normal" }
Button { label: "Turbo", variant: "primary", onClick: mode = "turbo" }
Button { label: "Zen", variant: "ghost", onClick: mode = "zen" }
]
match mode
"turbo" -> Badge { label: "🔥 TURBO", variant: "warning" }
"zen" -> Badge { label: "🧘 ZEN", variant: "info" }
_ -> Badge { label: "📝 Normal", variant: "default" }
]
-- 5. Comparison operators
Card { title: "Comparisons", subtitle: "> < >= <= == !=" } [
text "count = {count}"
when count > 50 -> Badge { label: "HIGH (>50)", variant: "warning" }
when count > 20 -> Badge { label: "MEDIUM (>20)", variant: "info" }
else -> Badge { label: "LOW (≤20)", variant: "success" }
]
]