569 lines
21 KiB
Rust
569 lines
21 KiB
Rust
//! Lexer for el-ui component syntax.
|
|
//!
|
|
//! Produces a flat `Vec<Token>` from source text.
|
|
//! The lexer is context-sensitive: it switches between "code mode"
|
|
//! and "template mode" when it encounters the `template` keyword and `{` / `}`.
|
|
|
|
use crate::error::{CompileError, CompileResult};
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Token {
|
|
// Keywords
|
|
Component,
|
|
Props,
|
|
State,
|
|
Fn,
|
|
Template,
|
|
If,
|
|
Else,
|
|
Return,
|
|
|
|
// Identifiers and literals
|
|
Ident(String),
|
|
StringLit(String),
|
|
IntLit(i64),
|
|
FloatLit(f64),
|
|
BoolLit(bool),
|
|
|
|
// Punctuation
|
|
LBrace, // {
|
|
RBrace, // }
|
|
LParen, // (
|
|
RParen, // )
|
|
LAngle, // <
|
|
RAngle, // >
|
|
LBracket, // [
|
|
RBracket, // ]
|
|
Colon, // :
|
|
Semicolon,// ;
|
|
Comma, // ,
|
|
Dot, // .
|
|
Eq, // =
|
|
EqEq, // ==
|
|
Bang, // !
|
|
BangEq, // !=
|
|
Plus, // +
|
|
Minus, // -
|
|
Star, // *
|
|
Slash, // /
|
|
Arrow, // ->
|
|
FatArrow, // =>
|
|
Ampersand,// &
|
|
Pipe, // |
|
|
AmpAmp, // &&
|
|
PipePipe, // ||
|
|
Question, // ?
|
|
Hash, // #
|
|
At, // @
|
|
|
|
// Template-specific
|
|
SlashIdent(String), // /if /each /activate
|
|
ColonIdent(String), // :else
|
|
HashIdent(String), // #if #each #activate
|
|
OnColon(String), // on:click on:input etc.
|
|
SelfClose, // />
|
|
CloseTag(String), // </div>
|
|
|
|
// Raw text in templates
|
|
RawText(String),
|
|
|
|
Eof,
|
|
}
|
|
|
|
pub fn tokenize(source: &str) -> CompileResult<Vec<Token>> {
|
|
let mut lexer = Lexer::new(source);
|
|
lexer.run()
|
|
}
|
|
|
|
struct Lexer<'a> {
|
|
src: &'a [u8],
|
|
pos: usize,
|
|
}
|
|
|
|
impl<'a> Lexer<'a> {
|
|
fn new(source: &'a str) -> Self {
|
|
Self { src: source.as_bytes(), pos: 0 }
|
|
}
|
|
|
|
fn peek(&self) -> Option<u8> {
|
|
self.src.get(self.pos).copied()
|
|
}
|
|
|
|
fn peek2(&self) -> Option<u8> {
|
|
self.src.get(self.pos + 1).copied()
|
|
}
|
|
|
|
fn advance(&mut self) -> Option<u8> {
|
|
let ch = self.src.get(self.pos).copied();
|
|
if ch.is_some() {
|
|
self.pos += 1;
|
|
}
|
|
ch
|
|
}
|
|
|
|
fn skip_whitespace_and_comments(&mut self) {
|
|
loop {
|
|
// Skip whitespace
|
|
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
|
|
self.advance();
|
|
}
|
|
// Skip // line comments
|
|
if self.peek() == Some(b'/') && self.peek2() == Some(b'/') {
|
|
while self.peek().is_some() && self.peek() != Some(b'\n') {
|
|
self.advance();
|
|
}
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
fn read_ident(&mut self) -> String {
|
|
let start = self.pos;
|
|
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) {
|
|
self.advance();
|
|
}
|
|
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
|
|
}
|
|
|
|
fn read_string(&mut self) -> CompileResult<String> {
|
|
// Opening quote already consumed
|
|
let mut s = String::new();
|
|
loop {
|
|
match self.advance() {
|
|
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated string".into() }),
|
|
Some(b'"') => break,
|
|
Some(b'\\') => {
|
|
match self.advance() {
|
|
Some(b'n') => s.push('\n'),
|
|
Some(b't') => s.push('\t'),
|
|
Some(b'r') => s.push('\r'),
|
|
Some(b'"') => s.push('"'),
|
|
Some(b'\\') => s.push('\\'),
|
|
Some(b'0') => s.push('\0'),
|
|
Some(c) => s.push(c as char),
|
|
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated escape".into() }),
|
|
}
|
|
}
|
|
Some(c) => s.push(c as char),
|
|
}
|
|
}
|
|
Ok(s)
|
|
}
|
|
|
|
fn read_number(&mut self, first: u8) -> Token {
|
|
let mut s = String::new();
|
|
s.push(first as char);
|
|
while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
|
|
let c = self.advance().unwrap();
|
|
if c != b'_' {
|
|
s.push(c as char);
|
|
}
|
|
}
|
|
if self.peek() == Some(b'.') && matches!(self.peek2(), Some(b'0'..=b'9')) {
|
|
s.push('.');
|
|
self.advance();
|
|
while matches!(self.peek(), Some(b'0'..=b'9')) {
|
|
s.push(self.advance().unwrap() as char);
|
|
}
|
|
Token::FloatLit(s.parse().unwrap_or(0.0))
|
|
} else {
|
|
Token::IntLit(s.parse().unwrap_or(0))
|
|
}
|
|
}
|
|
|
|
/// Read a template block — everything between the outer `{` and matching `}`
|
|
/// of `template { ... }`. Returns the raw text inside.
|
|
fn read_template_inner(&mut self) -> CompileResult<Vec<Token>> {
|
|
// We are positioned right after `template` keyword and the `{` that opened it.
|
|
// We tokenize the template body using a template-aware mini-lexer.
|
|
let mut toks: Vec<Token> = Vec::new();
|
|
let mut depth = 1i32; // we've consumed the opening {
|
|
let mut text_buf = String::new();
|
|
|
|
macro_rules! flush_text {
|
|
() => {
|
|
if !text_buf.is_empty() {
|
|
let t = text_buf.trim().to_owned();
|
|
if !t.is_empty() {
|
|
toks.push(Token::RawText(t));
|
|
}
|
|
text_buf.clear();
|
|
}
|
|
};
|
|
}
|
|
|
|
loop {
|
|
match self.peek() {
|
|
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated template block".into() }),
|
|
Some(b'{') => {
|
|
self.advance();
|
|
// Could be interpolation {expr}, or {#if}, {/if}, {:else}
|
|
// Peek at what follows
|
|
// Skip whitespace inside
|
|
while matches!(self.peek(), Some(b' ' | b'\t')) {
|
|
self.advance();
|
|
}
|
|
if self.peek() == Some(b'#') {
|
|
// Block tag: {#if ...} {#each ...} {#activate ...}
|
|
self.advance(); // consume #
|
|
let kw = self.read_ident();
|
|
flush_text!();
|
|
toks.push(Token::HashIdent(kw));
|
|
// Read the rest up to }
|
|
let mut inner = String::new();
|
|
let mut brace_d = 1i32;
|
|
loop {
|
|
match self.peek() {
|
|
None => break,
|
|
Some(b'{') => { brace_d += 1; inner.push('{'); self.advance(); }
|
|
Some(b'}') => {
|
|
brace_d -= 1;
|
|
self.advance();
|
|
if brace_d == 0 { break; }
|
|
inner.push('}');
|
|
}
|
|
Some(c) => { inner.push(c as char); self.advance(); }
|
|
}
|
|
}
|
|
toks.push(Token::RawText(inner.trim().to_owned()));
|
|
} else if self.peek() == Some(b'/') {
|
|
// Close tag: {/if} {/each} {/activate}
|
|
self.advance(); // consume /
|
|
let kw = self.read_ident();
|
|
flush_text!();
|
|
toks.push(Token::SlashIdent(kw));
|
|
while self.peek() == Some(b'}') { self.advance(); break; }
|
|
} else if self.peek() == Some(b':') {
|
|
// {:else}
|
|
self.advance(); // consume :
|
|
let kw = self.read_ident();
|
|
flush_text!();
|
|
toks.push(Token::ColonIdent(kw));
|
|
while self.peek() == Some(b'}') { self.advance(); break; }
|
|
} else {
|
|
// Regular interpolation or outer brace tracking
|
|
// Check if this closes the template
|
|
if depth == 1 && self.peek() == Some(b'}') {
|
|
// Empty brace—skip
|
|
self.advance();
|
|
let _ = depth - 1; // depth tracked by outer loop
|
|
break;
|
|
}
|
|
// Read the expression until matching }
|
|
let mut expr = String::new();
|
|
let mut brace_d = 1i32;
|
|
loop {
|
|
match self.peek() {
|
|
None => break,
|
|
Some(b'{') => { brace_d += 1; expr.push('{'); self.advance(); }
|
|
Some(b'}') => {
|
|
brace_d -= 1;
|
|
self.advance();
|
|
if brace_d == 0 { break; }
|
|
expr.push('}');
|
|
}
|
|
Some(c) => { expr.push(c as char); self.advance(); }
|
|
}
|
|
}
|
|
let expr = expr.trim().to_owned();
|
|
if !expr.is_empty() {
|
|
flush_text!();
|
|
toks.push(Token::LBrace);
|
|
toks.push(Token::RawText(expr));
|
|
toks.push(Token::RBrace);
|
|
}
|
|
}
|
|
}
|
|
Some(b'}') => {
|
|
depth -= 1;
|
|
self.advance();
|
|
if depth == 0 {
|
|
flush_text!();
|
|
break;
|
|
}
|
|
text_buf.push('}');
|
|
}
|
|
Some(b'<') => {
|
|
// HTML element or close tag
|
|
self.advance();
|
|
if self.peek() == Some(b'/') {
|
|
// Close tag </div>
|
|
self.advance();
|
|
let tag = self.read_tag_name();
|
|
while self.peek() != Some(b'>') && self.peek().is_some() {
|
|
self.advance();
|
|
}
|
|
self.advance(); // consume >
|
|
flush_text!();
|
|
toks.push(Token::CloseTag(tag));
|
|
} else {
|
|
// Open tag
|
|
let tag = self.read_tag_name();
|
|
flush_text!();
|
|
toks.push(Token::LAngle);
|
|
toks.push(Token::Ident(tag));
|
|
// Read attributes
|
|
self.read_attrs_into(&mut toks)?;
|
|
}
|
|
}
|
|
Some(b'\n' | b'\r') => {
|
|
self.advance();
|
|
text_buf.push(' ');
|
|
}
|
|
Some(c) => {
|
|
text_buf.push(c as char);
|
|
self.advance();
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(toks)
|
|
}
|
|
|
|
fn read_tag_name(&mut self) -> String {
|
|
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n')) {
|
|
self.advance();
|
|
}
|
|
let start = self.pos;
|
|
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_')) {
|
|
self.advance();
|
|
}
|
|
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
|
|
}
|
|
|
|
/// Read attributes until `>` or `/>`.
|
|
fn read_attrs_into(&mut self, toks: &mut Vec<Token>) -> CompileResult<()> {
|
|
loop {
|
|
// Skip whitespace
|
|
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
|
|
self.advance();
|
|
}
|
|
match self.peek() {
|
|
None => break,
|
|
Some(b'/') if self.peek2() == Some(b'>') => {
|
|
self.advance(); self.advance();
|
|
toks.push(Token::SelfClose);
|
|
break;
|
|
}
|
|
Some(b'>') => {
|
|
self.advance();
|
|
toks.push(Token::RAngle);
|
|
break;
|
|
}
|
|
Some(b'o') if self.src.get(self.pos..self.pos+3) == Some(b"on:") => {
|
|
// on:event={handler}
|
|
self.pos += 3; // skip "on:"
|
|
let event = self.read_tag_name();
|
|
// skip whitespace and =
|
|
while matches!(self.peek(), Some(b' ' | b'=')) { self.advance(); }
|
|
// read {expr}
|
|
let expr = if self.peek() == Some(b'{') {
|
|
self.advance();
|
|
self.read_until_brace_close()?
|
|
} else {
|
|
self.read_quoted_string()?
|
|
};
|
|
toks.push(Token::OnColon(event));
|
|
toks.push(Token::RawText(expr));
|
|
}
|
|
_ => {
|
|
// Regular attribute: name="val" or name={expr}
|
|
let name = self.read_attr_name();
|
|
if name.is_empty() { break; }
|
|
// Skip whitespace and =
|
|
while matches!(self.peek(), Some(b' ' | b'\t')) { self.advance(); }
|
|
if self.peek() != Some(b'=') {
|
|
// Boolean attribute with no value
|
|
toks.push(Token::Ident(name));
|
|
continue;
|
|
}
|
|
self.advance(); // consume =
|
|
while matches!(self.peek(), Some(b' ' | b'\t')) { self.advance(); }
|
|
let value = if self.peek() == Some(b'"') {
|
|
self.advance(); // consume "
|
|
let s = self.read_string()?;
|
|
toks.push(Token::Ident(name.clone()));
|
|
toks.push(Token::Eq);
|
|
toks.push(Token::StringLit(s));
|
|
continue;
|
|
} else if self.peek() == Some(b'{') {
|
|
self.advance();
|
|
self.read_until_brace_close()?
|
|
} else {
|
|
self.read_attr_name()
|
|
};
|
|
toks.push(Token::Ident(name));
|
|
toks.push(Token::Eq);
|
|
toks.push(Token::RawText(value));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_attr_name(&mut self) -> String {
|
|
let start = self.pos;
|
|
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b':')) {
|
|
self.advance();
|
|
}
|
|
String::from_utf8_lossy(&self.src[start..self.pos]).into_owned()
|
|
}
|
|
|
|
fn read_quoted_string(&mut self) -> CompileResult<String> {
|
|
if self.peek() == Some(b'"') {
|
|
self.advance();
|
|
self.read_string()
|
|
} else {
|
|
Ok(self.read_attr_name())
|
|
}
|
|
}
|
|
|
|
fn read_until_brace_close(&mut self) -> CompileResult<String> {
|
|
let mut s = String::new();
|
|
let mut depth = 1i32;
|
|
loop {
|
|
match self.peek() {
|
|
None => return Err(CompileError::Lex { pos: self.pos, msg: "unterminated {".into() }),
|
|
Some(b'{') => { depth += 1; s.push('{'); self.advance(); }
|
|
Some(b'}') => {
|
|
depth -= 1;
|
|
self.advance();
|
|
if depth == 0 { break; }
|
|
s.push('}');
|
|
}
|
|
Some(c) => { s.push(c as char); self.advance(); }
|
|
}
|
|
}
|
|
Ok(s)
|
|
}
|
|
|
|
fn run(&mut self) -> CompileResult<Vec<Token>> {
|
|
let mut tokens: Vec<Token> = Vec::new();
|
|
|
|
loop {
|
|
self.skip_whitespace_and_comments();
|
|
if self.peek().is_none() {
|
|
tokens.push(Token::Eof);
|
|
break;
|
|
}
|
|
|
|
let ch = self.advance().unwrap();
|
|
|
|
match ch {
|
|
b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
|
|
let mut ident = String::new();
|
|
ident.push(ch as char);
|
|
while matches!(self.peek(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) {
|
|
ident.push(self.advance().unwrap() as char);
|
|
}
|
|
let tok = match ident.as_str() {
|
|
"component" => Token::Component,
|
|
"props" => Token::Props,
|
|
"state" => Token::State,
|
|
"fn" => Token::Fn,
|
|
"template" => Token::Template,
|
|
"if" => Token::If,
|
|
"else" => Token::Else,
|
|
"return" => Token::Return,
|
|
"true" => Token::BoolLit(true),
|
|
"false" => Token::BoolLit(false),
|
|
other => Token::Ident(other.to_owned()),
|
|
};
|
|
// Special handling: after `template`, read the block specially
|
|
if tok == Token::Template {
|
|
tokens.push(tok);
|
|
self.skip_whitespace_and_comments();
|
|
if self.peek() == Some(b'{') {
|
|
self.advance(); // consume {
|
|
tokens.push(Token::LBrace);
|
|
let inner = self.read_template_inner()?;
|
|
tokens.extend(inner);
|
|
tokens.push(Token::RBrace);
|
|
}
|
|
} else {
|
|
tokens.push(tok);
|
|
}
|
|
}
|
|
b'"' => {
|
|
let s = self.read_string()?;
|
|
tokens.push(Token::StringLit(s));
|
|
}
|
|
b'0'..=b'9' => {
|
|
let tok = self.read_number(ch);
|
|
tokens.push(tok);
|
|
}
|
|
b'{' => tokens.push(Token::LBrace),
|
|
b'}' => tokens.push(Token::RBrace),
|
|
b'(' => tokens.push(Token::LParen),
|
|
b')' => tokens.push(Token::RParen),
|
|
b'[' => tokens.push(Token::LBracket),
|
|
b']' => tokens.push(Token::RBracket),
|
|
b':' => tokens.push(Token::Colon),
|
|
b';' => tokens.push(Token::Semicolon),
|
|
b',' => tokens.push(Token::Comma),
|
|
b'.' => tokens.push(Token::Dot),
|
|
b'=' => {
|
|
if self.peek() == Some(b'=') {
|
|
self.advance(); tokens.push(Token::EqEq);
|
|
} else if self.peek() == Some(b'>') {
|
|
self.advance(); tokens.push(Token::FatArrow);
|
|
} else {
|
|
tokens.push(Token::Eq);
|
|
}
|
|
}
|
|
b'!' => {
|
|
if self.peek() == Some(b'=') {
|
|
self.advance(); tokens.push(Token::BangEq);
|
|
} else {
|
|
tokens.push(Token::Bang);
|
|
}
|
|
}
|
|
b'+' => tokens.push(Token::Plus),
|
|
b'-' => {
|
|
if self.peek() == Some(b'>') {
|
|
self.advance(); tokens.push(Token::Arrow);
|
|
} else {
|
|
tokens.push(Token::Minus);
|
|
}
|
|
}
|
|
b'*' => tokens.push(Token::Star),
|
|
b'/' => {
|
|
if self.peek() == Some(b'/') {
|
|
// Line comment (shouldn't reach here after skip, but guard)
|
|
while self.peek().is_some() && self.peek() != Some(b'\n') {
|
|
self.advance();
|
|
}
|
|
} else {
|
|
tokens.push(Token::Slash);
|
|
}
|
|
}
|
|
b'&' => {
|
|
if self.peek() == Some(b'&') {
|
|
self.advance(); tokens.push(Token::AmpAmp);
|
|
} else {
|
|
tokens.push(Token::Ampersand);
|
|
}
|
|
}
|
|
b'|' => {
|
|
if self.peek() == Some(b'|') {
|
|
self.advance(); tokens.push(Token::PipePipe);
|
|
} else {
|
|
tokens.push(Token::Pipe);
|
|
}
|
|
}
|
|
b'?' => tokens.push(Token::Question),
|
|
b'#' => tokens.push(Token::Hash),
|
|
b'@' => tokens.push(Token::At),
|
|
b'<' => tokens.push(Token::LAngle),
|
|
b'>' => tokens.push(Token::RAngle),
|
|
_ => {
|
|
// Ignore unknown characters (whitespace already skipped)
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(tokens)
|
|
}
|
|
}
|