dreamstack/compiler/ds-parser/src/lexer.rs
enzotar 008f164ae7 feat: each loop, dreamstack init, expanded registry
Language:
- each item in list -> template (reactive list rendering)
- each/in tokens in lexer, Expr::Each in AST
- Reactive forEach codegen with scope push/pop
- Container trailing props: column [...] { variant: card }

CLI:
- dreamstack init [name] - scaffold new project
- Generates app.ds, components/, dreamstack.json
- 4 starter components (button, card, badge, input)

Registry expanded to 11 components:
- NEW: progress, alert, separator, toggle, avatar
- All embedded via include_str!

CSS: progress bar, avatar, separator, alert variants,
toggle switch, stat values (230+ lines design system)

Examples:
- each-demo.ds: list rendering demo
- dashboard.ds: glassmorphism cards with container variant
2026-02-26 14:42:00 -08:00

541 lines
19 KiB
Rust

/// DreamStack Lexer — tokenizes source into a stream of tokens.
#[derive(Debug, Clone, PartialEq)]
pub struct Token {
pub kind: TokenKind,
pub lexeme: String,
pub line: usize,
pub col: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
// Literals
Int(i64),
Float(f64),
StringStart, // opening "
StringFragment(String), // literal part of string
StringInterp, // { inside string
StringEnd, // closing "
True,
False,
// Identifiers & keywords
Ident(String),
Let,
View,
Effect,
On,
When,
Each,
InKw,
Match,
If,
Then,
Else,
Perform,
Handle,
With,
Stream,
From,
Spring,
Column,
Row,
Stack,
Panel,
List,
Form,
Scene,
Animate,
For,
In,
Component,
Route,
Navigate,
Constrain,
Pixel,
Delta,
Signals,
Every,
Import,
Export,
Type,
Where,
Layout,
// Operators
Plus,
Minus,
Star,
Slash,
Percent,
Eq, // =
EqEq, // ==
Neq, // !=
Lt, // <
Gt, // >
Lte, // <=
Gte, // >=
And, // &&
Or, // ||
Not, // !
PlusEq, // +=
MinusEq, // -=
Arrow, // ->
Pipe, // |
Dot, // .
// Delimiters
LParen,
RParen,
LBracket,
RBracket,
LBrace,
RBrace,
Comma,
Colon,
Newline,
// Special
Comment(String),
Eof,
Error(String),
}
pub struct Lexer {
source: Vec<char>,
pos: usize,
line: usize,
col: usize,
in_string: bool,
interp_depth: usize,
}
impl Lexer {
pub fn new(source: &str) -> Self {
Self {
source: source.chars().collect(),
pos: 0,
line: 1,
col: 1,
in_string: false,
interp_depth: 0,
}
}
pub fn tokenize(&mut self) -> Vec<Token> {
let mut tokens = Vec::new();
loop {
let tok = self.next_token();
let is_eof = tok.kind == TokenKind::Eof;
// Skip comments and consecutive newlines
match &tok.kind {
TokenKind::Comment(_) => continue,
TokenKind::Newline => {
if tokens.last().is_some_and(|t: &Token| t.kind == TokenKind::Newline) {
continue;
}
}
_ => {}
}
tokens.push(tok);
if is_eof {
break;
}
}
tokens
}
fn peek(&self) -> char {
self.source.get(self.pos).copied().unwrap_or('\0')
}
fn peek_next(&self) -> char {
self.source.get(self.pos + 1).copied().unwrap_or('\0')
}
fn advance(&mut self) -> char {
let c = self.peek();
self.pos += 1;
if c == '\n' {
self.line += 1;
self.col = 1;
} else {
self.col += 1;
}
c
}
fn make_token(&self, kind: TokenKind, lexeme: &str) -> Token {
Token {
kind,
lexeme: lexeme.to_string(),
line: self.line,
col: self.col,
}
}
fn skip_whitespace(&mut self) {
while self.pos < self.source.len() {
match self.peek() {
' ' | '\t' | '\r' => { self.advance(); }
_ => break,
}
}
}
fn next_token(&mut self) -> Token {
// If we're inside string interpolation and hit }, return to string mode
if self.in_string && self.interp_depth == 0 {
return self.lex_string_continuation();
}
self.skip_whitespace();
if self.pos >= self.source.len() {
return self.make_token(TokenKind::Eof, "");
}
let line = self.line;
let col = self.col;
let c = self.peek();
let tok = match c {
'\n' => { self.advance(); Token { kind: TokenKind::Newline, lexeme: "\n".into(), line, col } }
'-' if self.peek_next() == '-' => self.lex_comment(),
'-' if self.peek_next() == '>' => { self.advance(); self.advance(); Token { kind: TokenKind::Arrow, lexeme: "->".into(), line, col } }
'-' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::MinusEq, lexeme: "-=".into(), line, col } }
'+' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::PlusEq, lexeme: "+=".into(), line, col } }
'=' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::EqEq, lexeme: "==".into(), line, col } }
'!' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::Neq, lexeme: "!=".into(), line, col } }
'<' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::Lte, lexeme: "<=".into(), line, col } }
'>' if self.peek_next() == '=' => { self.advance(); self.advance(); Token { kind: TokenKind::Gte, lexeme: ">=".into(), line, col } }
'&' if self.peek_next() == '&' => { self.advance(); self.advance(); Token { kind: TokenKind::And, lexeme: "&&".into(), line, col } }
'|' 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 } }
'*' => { self.advance(); Token { kind: TokenKind::Star, lexeme: "*".into(), line, col } }
'/' if self.peek_next() == '/' => self.lex_comment(),
'/' => { 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 } }
'<' => { self.advance(); Token { kind: TokenKind::Lt, lexeme: "<".into(), line, col } }
'>' => { self.advance(); Token { kind: TokenKind::Gt, lexeme: ">".into(), line, col } }
'!' => { self.advance(); Token { kind: TokenKind::Not, lexeme: "!".into(), line, col } }
'|' => { self.advance(); Token { kind: TokenKind::Pipe, lexeme: "|".into(), line, col } }
'.' => { self.advance(); Token { kind: TokenKind::Dot, lexeme: ".".into(), line, col } }
'(' => { self.advance(); Token { kind: TokenKind::LParen, lexeme: "(".into(), line, col } }
')' => { self.advance(); Token { kind: TokenKind::RParen, lexeme: ")".into(), line, col } }
'[' => { self.advance(); Token { kind: TokenKind::LBracket, lexeme: "[".into(), line, col } }
']' => { self.advance(); Token { kind: TokenKind::RBracket, lexeme: "]".into(), line, col } }
'{' => {
self.advance();
if self.in_string {
self.interp_depth += 1;
}
Token { kind: TokenKind::LBrace, lexeme: "{".into(), line, col }
}
'}' => {
self.advance();
if self.interp_depth > 0 {
self.interp_depth -= 1;
}
Token { kind: TokenKind::RBrace, lexeme: "}".into(), line, col }
}
',' => { self.advance(); Token { kind: TokenKind::Comma, lexeme: ",".into(), line, col } }
':' => { self.advance(); Token { kind: TokenKind::Colon, lexeme: ":".into(), line, col } }
'"' => self.lex_string_start(),
c if c.is_ascii_digit() => self.lex_number(),
c if c.is_ascii_alphabetic() || c == '_' => self.lex_ident_or_keyword(),
_ => {
self.advance();
Token { kind: TokenKind::Error(format!("unexpected character: {c}")), lexeme: c.to_string(), line, col }
}
};
tok
}
fn lex_comment(&mut self) -> Token {
let line = self.line;
let col = self.col;
self.advance(); // -
self.advance(); // -
let mut text = String::new();
while self.pos < self.source.len() && self.peek() != '\n' {
text.push(self.advance());
}
Token { kind: TokenKind::Comment(text.trim().to_string()), lexeme: format!("--{text}"), line, col }
}
fn lex_number(&mut self) -> Token {
let line = self.line;
let col = self.col;
let mut num = String::new();
let mut is_float = false;
while self.pos < self.source.len() && (self.peek().is_ascii_digit() || self.peek() == '.') {
if self.peek() == '.' {
if is_float { break; }
// Check it's not a method call (e.g. `foo.bar`)
if self.peek_next().is_ascii_alphabetic() { break; }
is_float = true;
}
num.push(self.advance());
}
if is_float {
let val: f64 = num.parse().unwrap_or(0.0);
Token { kind: TokenKind::Float(val), lexeme: num, line, col }
} else {
let val: i64 = num.parse().unwrap_or(0);
Token { kind: TokenKind::Int(val), lexeme: num, line, col }
}
}
fn lex_ident_or_keyword(&mut self) -> Token {
let line = self.line;
let col = self.col;
let mut ident = String::new();
while self.pos < self.source.len() && (self.peek().is_ascii_alphanumeric() || self.peek() == '_') {
ident.push(self.advance());
}
let kind = match ident.as_str() {
"let" => TokenKind::Let,
"view" => TokenKind::View,
"effect" => TokenKind::Effect,
"on" => TokenKind::On,
"when" => TokenKind::When,
"each" => TokenKind::Each,
"in" => TokenKind::InKw,
"match" => TokenKind::Match,
"if" => TokenKind::If,
"then" => TokenKind::Then,
"else" => TokenKind::Else,
"perform" => TokenKind::Perform,
"handle" => TokenKind::Handle,
"with" => TokenKind::With,
"stream" => TokenKind::Stream,
"from" => TokenKind::From,
"spring" => TokenKind::Spring,
"constrain" => TokenKind::Constrain,
"pixel" => TokenKind::Pixel,
"delta" => TokenKind::Delta,
"signals" => TokenKind::Signals,
"column" => TokenKind::Column,
"row" => TokenKind::Row,
"stack" => TokenKind::Stack,
"panel" => TokenKind::Panel,
"list" => TokenKind::List,
"form" => TokenKind::Form,
"scene" => TokenKind::Scene,
"animate" => TokenKind::Animate,
"true" => TokenKind::True,
"false" => TokenKind::False,
"for" => TokenKind::For,
"in" => TokenKind::In,
"component" => TokenKind::Component,
"route" => TokenKind::Route,
"navigate" => TokenKind::Navigate,
"every" => TokenKind::Every,
"import" => TokenKind::Import,
"export" => TokenKind::Export,
"type" => TokenKind::Type,
"where" => TokenKind::Where,
"layout" => TokenKind::Layout,
_ => TokenKind::Ident(ident.clone()),
};
Token { kind, lexeme: ident, line, col }
}
fn lex_string_start(&mut self) -> Token {
let line = self.line;
let col = self.col;
self.advance(); // consume opening "
self.in_string = true;
// Now lex the string content
self.lex_string_body(line, col)
}
fn lex_string_continuation(&mut self) -> Token {
let line = self.line;
let col = self.col;
self.lex_string_body(line, col)
}
fn lex_string_body(&mut self, line: usize, col: usize) -> Token {
let mut text = String::new();
while self.pos < self.source.len() {
match self.peek() {
'"' => {
// End of string
self.advance();
self.in_string = false;
if text.is_empty() {
return Token { kind: TokenKind::StringEnd, lexeme: "\"".into(), line, col };
}
// Return fragment first, next call will return StringEnd
// Actually let's simplify: return the full string as a single token
return Token { kind: TokenKind::StringFragment(text.clone()), lexeme: format!("{text}\""), line, col };
}
'{' => {
if text.is_empty() {
// No text before { — emit StringInterp directly
self.advance();
self.interp_depth += 1;
return Token { kind: TokenKind::StringInterp, lexeme: "{".into(), line, col };
} else {
// Text before { — return the text fragment first.
// DON'T consume { — the next call to lex_string_body
// will see { at position 0 (empty text) and emit StringInterp.
return Token { kind: TokenKind::StringFragment(text.clone()), lexeme: text, line, col };
}
}
'\\' => {
self.advance();
match self.peek() {
'n' => { self.advance(); text.push('\n'); }
't' => { self.advance(); text.push('\t'); }
'\\' => { self.advance(); text.push('\\'); }
'"' => { self.advance(); text.push('"'); }
'{' => { self.advance(); text.push('{'); }
_ => { text.push('\\'); }
}
}
c => {
self.advance();
text.push(c);
}
}
}
// Unterminated string
Token { kind: TokenKind::Error("unterminated string".into()), lexeme: text, line, col }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_tokens() {
let mut lexer = Lexer::new("let count = 0");
let tokens = lexer.tokenize();
assert!(matches!(tokens[0].kind, TokenKind::Let));
assert!(matches!(&tokens[1].kind, TokenKind::Ident(s) if s == "count"));
assert!(matches!(tokens[2].kind, TokenKind::Eq));
assert!(matches!(tokens[3].kind, TokenKind::Int(0)));
}
#[test]
fn test_view_declaration() {
let mut lexer = Lexer::new("view counter =\n column [\n text label\n ]");
let tokens = lexer.tokenize();
assert!(matches!(tokens[0].kind, TokenKind::View));
assert!(matches!(&tokens[1].kind, TokenKind::Ident(s) if s == "counter"));
assert!(matches!(tokens[2].kind, TokenKind::Eq));
assert!(matches!(tokens[3].kind, TokenKind::Newline));
assert!(matches!(tokens[4].kind, TokenKind::Column));
}
#[test]
fn test_operators() {
let mut lexer = Lexer::new("count > 10 && x <= 5");
let tokens = lexer.tokenize();
assert!(matches!(tokens[1].kind, TokenKind::Gt));
assert!(matches!(tokens[3].kind, TokenKind::And));
assert!(matches!(tokens[5].kind, TokenKind::Lte));
}
#[test]
fn test_arrow() {
let mut lexer = Lexer::new("when x > 0 ->");
let tokens = lexer.tokenize();
assert!(matches!(tokens[4].kind, TokenKind::Arrow));
}
#[test]
fn test_string_simple() {
let mut lexer = Lexer::new(r#""hello world""#);
let tokens = lexer.tokenize();
assert!(matches!(&tokens[0].kind, TokenKind::StringFragment(s) if s == "hello world"));
}
#[test]
fn test_comment() {
let mut lexer = Lexer::new("let x = 5 -- this is a comment\nlet y = 10");
let tokens = lexer.tokenize();
// Comments are skipped
assert!(matches!(tokens[0].kind, TokenKind::Let));
assert!(matches!(tokens[3].kind, TokenKind::Int(5)));
assert!(matches!(tokens[4].kind, TokenKind::Newline));
assert!(matches!(tokens[5].kind, TokenKind::Let));
}
#[test]
fn test_slash_comment() {
let mut lexer = Lexer::new("// this is a comment\nlet y = 10");
let tokens = lexer.tokenize();
// // comments are also skipped
assert!(matches!(tokens[0].kind, TokenKind::Newline));
assert!(matches!(tokens[1].kind, TokenKind::Let));
}
#[test]
fn test_string_interpolation_tokens() {
let mut lexer = Lexer::new(r#""Hello {name}!""#);
let tokens = lexer.tokenize();
// Expected: StringFragment("Hello ") → StringInterp → Ident("name") → RBrace → StringFragment("!")
assert!(matches!(&tokens[0].kind, TokenKind::StringFragment(s) if s == "Hello "));
assert!(matches!(tokens[1].kind, TokenKind::StringInterp));
assert!(matches!(&tokens[2].kind, TokenKind::Ident(s) if s == "name"));
assert!(matches!(tokens[3].kind, TokenKind::RBrace));
assert!(matches!(&tokens[4].kind, TokenKind::StringFragment(s) if s == "!"));
}
#[test]
fn test_string_interpolation_at_start() {
let mut lexer = Lexer::new(r#""{count} items""#);
let tokens = lexer.tokenize();
// Expected: StringInterp → Ident("count") → RBrace → StringFragment(" items")
assert!(matches!(tokens[0].kind, TokenKind::StringInterp));
assert!(matches!(&tokens[1].kind, TokenKind::Ident(s) if s == "count"));
assert!(matches!(tokens[2].kind, TokenKind::RBrace));
assert!(matches!(&tokens[3].kind, TokenKind::StringFragment(s) if s == " items"));
}
#[test]
fn test_string_interpolation_multiple() {
let mut lexer = Lexer::new(r#""{a} and {b}""#);
let tokens = lexer.tokenize();
// StringInterp → Ident(a) → RBrace → StringFragment(" and ") → StringInterp → Ident(b) → RBrace → StringEnd
assert!(matches!(tokens[0].kind, TokenKind::StringInterp));
assert!(matches!(&tokens[1].kind, TokenKind::Ident(s) if s == "a"));
assert!(matches!(tokens[2].kind, TokenKind::RBrace));
assert!(matches!(&tokens[3].kind, TokenKind::StringFragment(s) if s == " and "));
assert!(matches!(tokens[4].kind, TokenKind::StringInterp));
assert!(matches!(&tokens[5].kind, TokenKind::Ident(s) if s == "b"));
assert!(matches!(tokens[6].kind, TokenKind::RBrace));
assert!(matches!(tokens[7].kind, TokenKind::StringEnd));
}
#[test]
fn test_string_no_interpolation() {
let mut lexer = Lexer::new(r#""plain string""#);
let tokens = lexer.tokenize();
assert!(matches!(&tokens[0].kind, TokenKind::StringFragment(s) if s == "plain string"));
}
#[test]
fn test_string_escaped_brace() {
let mut lexer = Lexer::new(r#""literal \{brace}""#);
let tokens = lexer.tokenize();
// \{ should be a literal { in the string, not interpolation
assert!(matches!(&tokens[0].kind, TokenKind::StringFragment(s) if s.contains("{")));
}
}