rename crates/ to engrams/; add el-compiler el package with bootstrap artifact

- crates/ → engrams/ (Rust engrams live here)
- el-compiler/ added: el self-hosting compiler as an el package
  - src/{compiler,lexer,parser,codegen}.el
  - bootstrap/el-compiler.elc (114KB, Rust-compiled seed)
- el.toml Cargo.toml workspace paths updated
- neuron-rs cross-repo path deps fixed (were pointing to products/ instead of foundation/)
This commit is contained in:
Will Anderson
2026-04-29 03:27:32 -05:00
parent 19ed2721ee
commit a42429012e
120 changed files with 3836 additions and 64 deletions
+35
View File
@@ -0,0 +1,35 @@
//! Lexer error types.
use thiserror::Error;
use crate::token::Span;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{kind} at {span}")]
pub struct LexError {
pub kind: LexErrorKind,
pub span: Span,
}
impl LexError {
pub fn new(kind: LexErrorKind, span: Span) -> Self {
Self { kind, span }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum LexErrorKind {
#[error("unexpected character {0:?}")]
UnexpectedChar(char),
#[error("unterminated string literal")]
UnterminatedString,
#[error("invalid escape sequence \\{0}")]
InvalidEscape(char),
#[error("invalid numeric literal")]
InvalidNumeric,
#[error("integer literal out of range")]
IntegerOverflow,
}
+592
View File
@@ -0,0 +1,592 @@
//! Single-pass lexer implementation.
use crate::error::{LexError, LexErrorKind};
use crate::token::{Span, Spanned, Token};
/// Tokenize an Engram source string.
///
/// Returns a `Vec` ending with a single [`Token::Eof`]. On the first
/// unrecognised character the function returns an error; partial output
/// is discarded.
pub fn tokenize(source: &str) -> Result<Vec<Spanned<Token>>, LexError> {
let mut lex = Lexer::new(source);
lex.scan_all()
}
// ── Lexer state ──────────────────────────────────────────────────────────────
struct Lexer<'src> {
src: &'src str,
/// Current byte position into `src`.
pos: usize,
line: u32,
/// Byte position of the start of the current line (for computing columns).
line_start: usize,
}
impl<'src> Lexer<'src> {
fn new(src: &'src str) -> Self {
Self { src, pos: 0, line: 1, line_start: 0 }
}
// ── Utilities ─────────────────────────────────────────────────────────────
fn col_at(&self, byte_pos: usize) -> u32 {
(byte_pos - self.line_start + 1) as u32
}
fn span_from(&self, start: usize) -> Span {
Span::new(start, self.pos, self.line, self.col_at(start))
}
fn peek(&self) -> Option<char> {
self.src[self.pos..].chars().next()
}
fn peek2(&self) -> Option<char> {
let mut chars = self.src[self.pos..].chars();
chars.next();
chars.next()
}
fn advance(&mut self) -> Option<char> {
let ch = self.peek()?;
self.pos += ch.len_utf8();
if ch == '\n' {
self.line += 1;
self.line_start = self.pos;
}
Some(ch)
}
/// Consume the next character only if it matches `expected`.
fn eat(&mut self, expected: char) -> bool {
if self.peek() == Some(expected) {
self.advance();
true
} else {
false
}
}
fn at_end(&self) -> bool {
self.pos >= self.src.len()
}
fn spanned(&self, tok: Token, start: usize) -> Spanned<Token> {
Spanned::new(tok, self.span_from(start))
}
// ── Main scan loop ────────────────────────────────────────────────────────
fn scan_all(&mut self) -> Result<Vec<Spanned<Token>>, LexError> {
let mut tokens = Vec::new();
loop {
self.skip_whitespace_and_comments();
if self.at_end() {
let span = Span::point(self.pos, self.line, self.col_at(self.pos));
tokens.push(Spanned::new(Token::Eof, span));
break;
}
let tok = self.scan_token()?;
tokens.push(tok);
}
Ok(tokens)
}
fn skip_whitespace_and_comments(&mut self) {
loop {
// Skip whitespace
while let Some(ch) = self.peek() {
if ch.is_whitespace() {
self.advance();
} else {
break;
}
}
// Skip line comments `// ...`
if self.peek() == Some('/') && self.peek2() == Some('/') {
self.advance(); // first /
self.advance(); // second /
while let Some(ch) = self.peek() {
self.advance();
if ch == '\n' {
break;
}
}
} else {
break;
}
}
}
fn scan_token(&mut self) -> Result<Spanned<Token>, LexError> {
let start = self.pos;
let ch = self.advance().unwrap();
let tok = match ch {
// ── Delimiters ──────────────────────────────────────────────────
'(' => Token::LParen,
')' => Token::RParen,
'{' => Token::LBrace,
'}' => Token::RBrace,
'[' => Token::LBracket,
']' => Token::RBracket,
',' => Token::Comma,
'.' => Token::Dot,
';' => Token::Semicolon,
// ── Operators that may be multi-char ────────────────────────────
'+' => Token::Plus,
'*' => Token::Star,
'-' => {
if self.eat('>') {
Token::Arrow
} else {
Token::Minus
}
}
'/' => Token::Slash,
'=' => {
if self.eat('=') {
Token::EqEq
} else if self.eat('>') {
Token::FatArrow
} else {
Token::Eq
}
}
'!' => {
if self.eat('=') {
Token::NotEq
} else {
Token::Not
}
}
'<' => {
if self.eat('=') {
Token::LtEq
} else {
Token::Lt
}
}
'>' => {
if self.eat('=') {
Token::GtEq
} else {
Token::Gt
}
}
'&' => {
if self.eat('&') {
Token::And
} else {
return Err(LexError::new(
LexErrorKind::UnexpectedChar('&'),
self.span_from(start),
));
}
}
'|' => {
if self.eat('|') {
Token::Or
} else if self.eat('>') {
Token::PipeOp
} else {
Token::Pipe
}
}
'@' => Token::At,
'?' => Token::QuestionMark,
':' => {
if self.eat(':') {
Token::ColonColon
} else {
Token::Colon
}
}
// ── String literals ──────────────────────────────────────────────
'"' => self.scan_string(start)?,
// ── Numeric literals ─────────────────────────────────────────────
c if c.is_ascii_digit() => self.scan_number(start, c)?,
// ── Identifiers and keywords ─────────────────────────────────────
c if c.is_alphabetic() || c == '_' => self.scan_ident_or_keyword(start, c),
other => {
return Err(LexError::new(
LexErrorKind::UnexpectedChar(other),
self.span_from(start),
))
}
};
Ok(self.spanned(tok, start))
}
// ── String scanning ───────────────────────────────────────────────────────
fn scan_string(&mut self, start: usize) -> Result<Token, LexError> {
let mut s = String::new();
loop {
match self.peek() {
None => {
return Err(LexError::new(
LexErrorKind::UnterminatedString,
self.span_from(start),
))
}
Some('"') => {
self.advance();
return Ok(Token::StringLiteral(s));
}
Some('\\') => {
self.advance(); // consume backslash
match self.peek() {
Some('n') => { self.advance(); s.push('\n'); }
Some('t') => { self.advance(); s.push('\t'); }
Some('r') => { self.advance(); s.push('\r'); }
Some('"') => { self.advance(); s.push('"'); }
Some('\\') => { self.advance(); s.push('\\'); }
Some('0') => { self.advance(); s.push('\0'); }
Some(c) => {
let esc = c;
self.advance();
return Err(LexError::new(
LexErrorKind::InvalidEscape(esc),
self.span_from(start),
));
}
None => {
return Err(LexError::new(
LexErrorKind::UnterminatedString,
self.span_from(start),
))
}
}
}
Some(c) => {
s.push(c);
self.advance();
}
}
}
}
// ── Numeric scanning ──────────────────────────────────────────────────────
fn scan_number(&mut self, start: usize, first: char) -> Result<Token, LexError> {
let mut raw = String::new();
raw.push(first);
let mut is_float = false;
// Collect digits
while let Some(c) = self.peek() {
if c.is_ascii_digit() || c == '_' {
raw.push(c);
self.advance();
} else if c == '.' && self.peek2().is_some_and(|d| d.is_ascii_digit()) {
is_float = true;
raw.push(c);
self.advance();
} else {
break;
}
}
if is_float {
let clean: String = raw.chars().filter(|&c| c != '_').collect();
let v: f64 = clean.parse().map_err(|_| LexError::new(
LexErrorKind::InvalidNumeric,
self.span_from(start),
))?;
Ok(Token::FloatLiteral(v))
} else {
let clean: String = raw.chars().filter(|&c| c != '_').collect();
let v: i64 = clean.parse().map_err(|_| LexError::new(
LexErrorKind::IntegerOverflow,
self.span_from(start),
))?;
Ok(Token::IntLiteral(v))
}
}
// ── Identifier / keyword scanning ─────────────────────────────────────────
fn scan_ident_or_keyword(&mut self, _start: usize, first: char) -> Token {
let mut s = String::new();
s.push(first);
while let Some(c) = self.peek() {
if c.is_alphanumeric() || c == '_' {
s.push(c);
self.advance();
} else {
break;
}
}
keyword_or_ident(s)
}
}
// ── Keyword table ─────────────────────────────────────────────────────────────
fn keyword_or_ident(s: String) -> Token {
match s.as_str() {
"let" => Token::Let,
"fn" => Token::Fn,
"type" => Token::Type,
"enum" => Token::Enum,
"match" => Token::Match,
"return" => Token::Return,
"activate" => Token::Activate,
"where" => Token::Where,
"sealed" => Token::Sealed,
"if" => Token::If,
"else" => Token::Else,
"for" => Token::For,
"in" => Token::In,
"while" => Token::While,
"test" => Token::Test,
"seed" => Token::Seed,
"assert" => Token::Assert,
"target" => Token::Target,
"protocol" => Token::Protocol,
"impl" => Token::Impl,
"import" => Token::Import,
"from" => Token::From,
"as" => Token::As,
"true" => Token::BoolLiteral(true),
"false" => Token::BoolLiteral(false),
"with" => Token::With,
"retry" => Token::Retry,
"times" => Token::Times,
"fallback" => Token::Fallback,
"reason" => Token::Reason,
"parallel" => Token::Parallel,
"trace" => Token::Trace,
"requires" => Token::Requires,
"deploy" => Token::Deploy,
"to" => Token::To,
"via" => Token::Via,
_ => Token::Ident(s),
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::token::Token;
fn toks(src: &str) -> Vec<Token> {
tokenize(src).unwrap().into_iter().map(|s| s.node).collect()
}
#[test]
fn test_empty_source() {
let result = tokenize("").unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].node, Token::Eof);
}
#[test]
fn test_keywords() {
let src = "let fn type enum match return activate where sealed if else for in";
let tokens = toks(src);
assert_eq!(tokens[0], Token::Let);
assert_eq!(tokens[1], Token::Fn);
assert_eq!(tokens[2], Token::Type);
assert_eq!(tokens[3], Token::Enum);
assert_eq!(tokens[4], Token::Match);
assert_eq!(tokens[5], Token::Return);
assert_eq!(tokens[6], Token::Activate);
assert_eq!(tokens[7], Token::Where);
assert_eq!(tokens[8], Token::Sealed);
assert_eq!(tokens[9], Token::If);
assert_eq!(tokens[10], Token::Else);
assert_eq!(tokens[11], Token::For);
assert_eq!(tokens[12], Token::In);
}
#[test]
fn test_bool_literals() {
let tokens = toks("true false");
assert_eq!(tokens[0], Token::BoolLiteral(true));
assert_eq!(tokens[1], Token::BoolLiteral(false));
}
#[test]
fn test_int_literal() {
let tokens = toks("42 0 1_000_000");
assert_eq!(tokens[0], Token::IntLiteral(42));
assert_eq!(tokens[1], Token::IntLiteral(0));
assert_eq!(tokens[2], Token::IntLiteral(1_000_000));
}
#[test]
fn test_float_literal() {
let tokens = toks("3.14 0.5");
assert_eq!(tokens[0], Token::FloatLiteral(3.14));
assert_eq!(tokens[1], Token::FloatLiteral(0.5));
}
#[test]
fn test_string_literal() {
let tokens = toks(r#""hello" "world\n""#);
assert_eq!(tokens[0], Token::StringLiteral("hello".into()));
assert_eq!(tokens[1], Token::StringLiteral("world\n".into()));
}
#[test]
fn test_operators() {
let src = "+ - * / = == != < > <= >= && || ! -> =>";
let tokens = toks(src);
assert_eq!(tokens[0], Token::Plus);
assert_eq!(tokens[1], Token::Minus);
assert_eq!(tokens[2], Token::Star);
assert_eq!(tokens[3], Token::Slash);
assert_eq!(tokens[4], Token::Eq);
assert_eq!(tokens[5], Token::EqEq);
assert_eq!(tokens[6], Token::NotEq);
assert_eq!(tokens[7], Token::Lt);
assert_eq!(tokens[8], Token::Gt);
assert_eq!(tokens[9], Token::LtEq);
assert_eq!(tokens[10], Token::GtEq);
assert_eq!(tokens[11], Token::And);
assert_eq!(tokens[12], Token::Or);
assert_eq!(tokens[13], Token::Not);
assert_eq!(tokens[14], Token::Arrow);
assert_eq!(tokens[15], Token::FatArrow);
}
#[test]
fn test_delimiters() {
let src = "( ) { } [ ] , : :: . ;";
let tokens = toks(src);
assert_eq!(tokens[0], Token::LParen);
assert_eq!(tokens[1], Token::RParen);
assert_eq!(tokens[2], Token::LBrace);
assert_eq!(tokens[3], Token::RBrace);
assert_eq!(tokens[4], Token::LBracket);
assert_eq!(tokens[5], Token::RBracket);
assert_eq!(tokens[6], Token::Comma);
assert_eq!(tokens[7], Token::Colon);
assert_eq!(tokens[8], Token::ColonColon);
assert_eq!(tokens[9], Token::Dot);
assert_eq!(tokens[10], Token::Semicolon);
}
#[test]
fn test_line_comment_skipped() {
let tokens = toks("let // this is a comment\nfn");
assert_eq!(tokens[0], Token::Let);
assert_eq!(tokens[1], Token::Fn);
}
#[test]
fn test_span_line_col() {
let src = "let\nfn";
let tokens = tokenize(src).unwrap();
assert_eq!(tokens[0].span.line, 1);
assert_eq!(tokens[0].span.col, 1);
assert_eq!(tokens[1].span.line, 2);
assert_eq!(tokens[1].span.col, 1);
}
#[test]
fn test_unterminated_string_error() {
let result = tokenize(r#""unterminated"#);
assert!(result.is_err());
}
#[test]
fn test_at_token() {
let tokens = toks("@public");
assert_eq!(tokens[0], Token::At);
assert_eq!(tokens[1], Token::Ident("public".into()));
}
#[test]
fn test_hello_world_program() {
let src = r#"
fn greet(name: String) -> String {
return "Hello, " + name
}
let msg: String = greet("Will")
"#;
let tokens = tokenize(src).unwrap();
// Verify it tokenizes without error and the last token is EOF
assert_eq!(tokens.last().unwrap().node, Token::Eof);
// Should have a reasonable number of tokens
assert!(tokens.len() > 10);
}
#[test]
fn test_activate_syntax() {
let src = r#"activate User where "customer who purchased recently""#;
let tokens = toks(src);
assert_eq!(tokens[0], Token::Activate);
assert_eq!(tokens[1], Token::Ident("User".into()));
assert_eq!(tokens[2], Token::Where);
assert_eq!(tokens[3], Token::StringLiteral("customer who purchased recently".into()));
}
#[test]
fn test_colon_colon_path() {
let tokens = toks("Status::Active");
assert_eq!(tokens[0], Token::Ident("Status".into()));
assert_eq!(tokens[1], Token::ColonColon);
assert_eq!(tokens[2], Token::Ident("Active".into()));
}
#[test]
fn test_new_keywords() {
let tokens = toks("protocol impl import from as");
assert_eq!(tokens[0], Token::Protocol);
assert_eq!(tokens[1], Token::Impl);
assert_eq!(tokens[2], Token::Import);
assert_eq!(tokens[3], Token::From);
assert_eq!(tokens[4], Token::As);
}
#[test]
fn test_pipe_token() {
let tokens = toks("|x: Int|");
assert_eq!(tokens[0], Token::Pipe);
assert_eq!(tokens[1], Token::Ident("x".into()));
assert_eq!(tokens[4], Token::Pipe);
}
#[test]
fn test_question_mark_token() {
let tokens = toks("x?");
assert_eq!(tokens[0], Token::Ident("x".into()));
assert_eq!(tokens[1], Token::QuestionMark);
}
#[test]
fn test_ident_with_underscore() {
let tokens = toks("my_var _private __double");
assert_eq!(tokens[0], Token::Ident("my_var".into()));
assert_eq!(tokens[1], Token::Ident("_private".into()));
assert_eq!(tokens[2], Token::Ident("__double".into()));
}
#[test]
fn test_sealed_block_tokens() {
// sealed { let x: String = "secret" }
// [0]=Sealed [1]={ [2]=let [3]=x [4]=: [5]=String [6]== [7]="secret" [8]=}
let src = "sealed { let x: String = \"secret\" }";
let tokens = toks(src);
assert_eq!(tokens[0], Token::Sealed);
assert_eq!(tokens[1], Token::LBrace);
assert_eq!(tokens[2], Token::Let);
assert_eq!(tokens[7], Token::StringLiteral("secret".into()));
assert_eq!(tokens[8], Token::RBrace);
}
}
+19
View File
@@ -0,0 +1,19 @@
//! el-lexer — Engram language tokenizer.
//!
//! Converts source text into a flat stream of [`Spanned<Token>`] values.
//! All spans carry byte offsets, line, and column so that the parser and
//! diagnostics engine can point back to exact source positions.
//!
//! # Design
//! - Single-pass; O(n) in source length.
//! - No heap allocation per character — only allocates when producing
//! string / identifier token payloads.
//! - All errors carry a [`Span`] so the caller can produce good diagnostics.
mod error;
mod lexer;
mod token;
pub use error::{LexError, LexErrorKind};
pub use lexer::tokenize;
pub use token::{Span, Spanned, Token};
+278
View File
@@ -0,0 +1,278 @@
//! Token definitions and span types.
/// A span in the source file — byte offsets plus human-readable location.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
/// Byte offset of the first character of this token.
pub start: usize,
/// Byte offset one past the last character of this token.
pub end: usize,
/// 1-based line number.
pub line: u32,
/// 1-based column number (byte column within the line).
pub col: u32,
}
impl Span {
pub fn new(start: usize, end: usize, line: u32, col: u32) -> Self {
Self { start, end, line, col }
}
/// A zero-width span at the given position (used for EOF).
pub fn point(pos: usize, line: u32, col: u32) -> Self {
Self { start: pos, end: pos, line, col }
}
}
impl std::fmt::Display for Span {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.line, self.col)
}
}
/// A value annotated with its source location.
#[derive(Debug, Clone, PartialEq)]
pub struct Spanned<T> {
pub node: T,
pub span: Span,
}
impl<T> Spanned<T> {
pub fn new(node: T, span: Span) -> Self {
Self { node, span }
}
}
/// All tokens the Engram language lexer can produce.
#[derive(Debug, Clone, PartialEq)]
pub enum Token {
// ── Keywords ──────────────────────────────────────────────────────────────
/// `let`
Let,
/// `fn`
Fn,
/// `type`
Type,
/// `enum`
Enum,
/// `match`
Match,
/// `return`
Return,
/// `activate` — the spreading-activation query construct
Activate,
/// `where` — used in `activate T where "query"`
Where,
/// `sealed` — quantum-sealed block
Sealed,
/// `if`
If,
/// `else`
Else,
/// `for`
For,
/// `in`
In,
/// `while`
While,
/// `test` — test block definition
Test,
/// `seed` — graph seeding statement inside a test
Seed,
/// `assert` — assertion statement inside a test
Assert,
/// `target` — test target annotation (`target: e2e`)
Target,
/// `protocol` — protocol definition
Protocol,
/// `impl` — protocol implementation block
Impl,
/// `import` — import statement
Import,
/// `from` — `from package import { ... }`
From,
/// `as` — alias in import (`import X as Y`)
As,
/// `with` — record update syntax
With,
/// `retry` — retry block
Retry,
/// `times` — used in `retry N times`
Times,
/// `fallback` — fallback block in retry
Fallback,
/// `reason` — AI inference primitive
Reason,
/// `parallel` — concurrent execution block
Parallel,
/// `trace` — zero-cost observability block
Trace,
/// `requires` — precondition annotation on fn
Requires,
/// `deploy` — deployment primitive
Deploy,
/// `to` — used in `deploy fn to "/route"`
To,
/// `via` — used in `deploy fn to "/route" via soma`
Via,
/// `|>` — pipe operator
PipeOp,
/// `true` / `false`
BoolLiteral(bool),
// ── Literals ──────────────────────────────────────────────────────────────
IntLiteral(i64),
FloatLiteral(f64),
/// String literal with escape sequences already resolved.
StringLiteral(String),
// ── Identifiers ───────────────────────────────────────────────────────────
Ident(String),
// ── Operators ─────────────────────────────────────────────────────────────
/// `+`
Plus,
/// `-`
Minus,
/// `*`
Star,
/// `/`
Slash,
/// `=`
Eq,
/// `==`
EqEq,
/// `!=`
NotEq,
/// `<`
Lt,
/// `>`
Gt,
/// `<=`
LtEq,
/// `>=`
GtEq,
/// `&&`
And,
/// `||`
Or,
/// `!`
Not,
/// `->` (function return type arrow)
Arrow,
/// `=>` (match arm)
FatArrow,
// ── Delimiters ────────────────────────────────────────────────────────────
/// `(`
LParen,
/// `)`
RParen,
/// `{`
LBrace,
/// `}`
RBrace,
/// `[`
LBracket,
/// `]`
RBracket,
/// `,`
Comma,
/// `:`
Colon,
/// `::`
ColonColon,
/// `.`
Dot,
/// `;`
Semicolon,
// ── New single-char tokens ────────────────────────────────────────────────
/// `@` — decorator prefix
At,
/// `|` — closure param delimiter (single pipe, not `||`)
Pipe,
/// `?` — used both for Optional types and the Try operator
QuestionMark,
// ── Special ───────────────────────────────────────────────────────────────
Eof,
}
impl std::fmt::Display for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Token::Let => write!(f, "let"),
Token::Fn => write!(f, "fn"),
Token::Type => write!(f, "type"),
Token::Enum => write!(f, "enum"),
Token::Match => write!(f, "match"),
Token::Return => write!(f, "return"),
Token::Activate => write!(f, "activate"),
Token::Where => write!(f, "where"),
Token::Sealed => write!(f, "sealed"),
Token::If => write!(f, "if"),
Token::Else => write!(f, "else"),
Token::For => write!(f, "for"),
Token::While => write!(f, "while"),
Token::In => write!(f, "in"),
Token::Test => write!(f, "test"),
Token::Seed => write!(f, "seed"),
Token::Assert => write!(f, "assert"),
Token::Target => write!(f, "target"),
Token::Protocol => write!(f, "protocol"),
Token::Impl => write!(f, "impl"),
Token::Import => write!(f, "import"),
Token::From => write!(f, "from"),
Token::As => write!(f, "as"),
Token::With => write!(f, "with"),
Token::Retry => write!(f, "retry"),
Token::Times => write!(f, "times"),
Token::Fallback => write!(f, "fallback"),
Token::Reason => write!(f, "reason"),
Token::Parallel => write!(f, "parallel"),
Token::Trace => write!(f, "trace"),
Token::Requires => write!(f, "requires"),
Token::Deploy => write!(f, "deploy"),
Token::To => write!(f, "to"),
Token::Via => write!(f, "via"),
Token::PipeOp => write!(f, "|>"),
Token::At => write!(f, "@"),
Token::Pipe => write!(f, "|"),
Token::QuestionMark => write!(f, "?"),
Token::BoolLiteral(b) => write!(f, "{b}"),
Token::IntLiteral(n) => write!(f, "{n}"),
Token::FloatLiteral(n) => write!(f, "{n}"),
Token::StringLiteral(s) => write!(f, "\"{s}\""),
Token::Ident(s) => write!(f, "{s}"),
Token::Plus => write!(f, "+"),
Token::Minus => write!(f, "-"),
Token::Star => write!(f, "*"),
Token::Slash => write!(f, "/"),
Token::Eq => write!(f, "="),
Token::EqEq => write!(f, "=="),
Token::NotEq => write!(f, "!="),
Token::Lt => write!(f, "<"),
Token::Gt => write!(f, ">"),
Token::LtEq => write!(f, "<="),
Token::GtEq => write!(f, ">="),
Token::And => write!(f, "&&"),
Token::Or => write!(f, "||"),
Token::Not => write!(f, "!"),
Token::Arrow => write!(f, "->"),
Token::FatArrow => write!(f, "=>"),
Token::LParen => write!(f, "("),
Token::RParen => write!(f, ")"),
Token::LBrace => write!(f, "{{"),
Token::RBrace => write!(f, "}}"),
Token::LBracket => write!(f, "["),
Token::RBracket => write!(f, "]"),
Token::Comma => write!(f, ","),
Token::Colon => write!(f, ":"),
Token::ColonColon => write!(f, "::"),
Token::Dot => write!(f, "."),
Token::Semicolon => write!(f, ";"),
Token::Eof => write!(f, "<eof>"),
}
}
}