feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user