diff --git a/.github/workflows/build-v2.yml b/.github/workflows/build-v2.yml new file mode 100644 index 0000000..812f2e1 --- /dev/null +++ b/.github/workflows/build-v2.yml @@ -0,0 +1,99 @@ +name: Build v2 +on: + push: + branches: [ '*' ] + paths-ignore: + - "**/docs/**" + - "**.md" + pull_request: + branches: [ main ] + workflow_call: + +jobs: + check: + name: build crate + strategy: + fail-fast: false + matrix: + version: [ 'macos-latest', 'ubuntu-latest', 'windows-latest'] + rust: [ nightly, stable ] + runs-on: ${{ matrix.version }} + steps: + - uses: actions/checkout@v2 + - name: setup | rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + default: true + profile: minimal + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v1 + - run: cargo install cargo-insta + - run: cargo check + continue-on-error: ${{ matrix.rust == 'nightly' }} + - run: cargo fmt --all -- --check + continue-on-error: ${{ matrix.rust == 'nightly' }} + - run: cargo clippy --all-targets --all-features -- -D warnings + continue-on-error: ${{ matrix.rust == 'nightly' }} + - run: cargo test --all --locked -- -Z unstable-options + continue-on-error: ${{ matrix.rust == 'nightly' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: cargo insta test + continue-on-error: ${{ matrix.rust == 'nightly' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: smoke tests + run: | + cargo run -- --version + cargo run -- --help + + audit: + name: security audit + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: setup | rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + default: true + profile: minimal + - uses: Swatinem/rust-cache@v1 + - name: audit + uses: actions-rs/audit-check@v1 + continue-on-error: true + with: + token: ${{ secrets.GITHUB_TOKEN }} + + publish-dry-run: + name: publish dry run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: setup | rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + default: true + profile: minimal + - uses: Swatinem/rust-cache@v1 + - run: cargo publish --dry-run -p curlz + + docs: + name: docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: setup | rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + default: true + profile: minimal + - uses: Swatinem/rust-cache@v1 + - name: check documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --all-features diff --git a/curlz/src/curlz/templ-lang/ast.rs b/curlz/src/curlz/templ-lang/ast.rs new file mode 100644 index 0000000..1a682ae --- /dev/null +++ b/curlz/src/curlz/templ-lang/ast.rs @@ -0,0 +1,91 @@ +use crate::language::tokens::Span; + +// using toml::Value here only because of lazyness +pub use toml::Value; + +#[derive(Debug)] +pub struct Spanned { + pub node: Box, + pub span: Span, +} + +impl Spanned { + pub fn new(node: T, span: Span) -> Self { + Self { + node: Box::new(node), + span, + } + } +} + +#[derive(Debug)] +pub struct Template<'a> { + pub children: Vec>, +} + +#[derive(Debug)] +pub struct Var<'a> { + pub id: &'a str, +} + +#[derive(Debug)] +pub struct SysVar<'a> { + pub id: &'a str, +} + +#[derive(Debug)] +pub struct EmitRaw<'a> { + pub raw: &'a str, +} + +#[derive(Debug)] +pub struct EmitExpr<'a> { + pub expr: Expr<'a>, +} + +#[derive(Debug)] +pub struct Const { + pub value: Value, +} + +#[derive(Debug)] +pub struct Call<'a> { + pub expr: Expr<'a>, + pub args: Vec>, +} + +#[derive(Debug)] +pub enum Expr<'a> { + SysVar(Spanned>), + Var(Spanned>), + Const(Spanned), + Call(Spanned>), +} + +#[derive(Debug)] +pub enum Stmt<'a> { + Template(Spanned>), + EmitRaw(Spanned>), + EmitExpr(Spanned>), +} + +#[cfg(test)] +pub trait IntoSpanned { + fn spanned(self) -> Spanned + where + Self: Sized, + { + Spanned::new( + self, + Span { + start_line: 1, + start_col: 0, + end_line: 1, + end_col: 1, + }, + ) + } +} + +#[cfg(test)] +impl IntoSpanned for T {} diff --git a/curlz/src/curlz/templ-lang/ast_visitor.rs b/curlz/src/curlz/templ-lang/ast_visitor.rs new file mode 100644 index 0000000..b562d55 --- /dev/null +++ b/curlz/src/curlz/templ-lang/ast_visitor.rs @@ -0,0 +1,33 @@ +/*! +this module contains AST related tooling such as the visitor trait and +the double dispatch (impl of [`AstVisitAcceptor`]) for all AST nodes. +*/ +use crate::language::ast; + +pub trait AstVisitAcceptor<'ast> { + fn accept>(&self, visitor: &mut V); +} + +pub trait AstVisit<'ast> { + fn visit_stmt(&mut self, _stmt: &ast::Stmt<'ast>) {} + fn visit_expr(&mut self, _expr: &ast::Expr<'ast>) {} + fn visit_emit_raw(&mut self, _raw: &ast::EmitRaw<'ast>) {} +} + +impl<'ast> AstVisitAcceptor<'ast> for ast::Stmt<'ast> { + fn accept>(&self, visitor: &mut V) { + visitor.visit_stmt(self); + } +} + +impl<'ast> AstVisitAcceptor<'ast> for ast::Expr<'ast> { + fn accept>(&self, visitor: &mut V) { + visitor.visit_expr(self); + } +} + +impl<'ast> AstVisitAcceptor<'ast> for ast::EmitRaw<'ast> { + fn accept>(&self, visitor: &mut V) { + visitor.visit_emit_raw(self); + } +} diff --git a/curlz/src/curlz/templ-lang/lexer.rs b/curlz/src/curlz/templ-lang/lexer.rs new file mode 100644 index 0000000..5c80ab7 --- /dev/null +++ b/curlz/src/curlz/templ-lang/lexer.rs @@ -0,0 +1,254 @@ +use anyhow::{anyhow, Error}; +use std::ops::Not; + +use crate::language::tokens::{Span, Token}; + +enum LexerState { + Template, + InVariable, +} + +struct TokenizerState<'s> { + stack: Vec, + rest: &'s str, + failed: bool, + current_line: usize, + current_col: usize, +} + +impl<'s> TokenizerState<'s> {} + +impl<'s> TokenizerState<'s> { + /// advance by `n_bytes` and keeps track of the position in the stream + fn advance(&mut self, n_bytes: usize) -> &'s str { + let (skipped, new_rest) = self.rest.split_at(n_bytes); + self.rest = new_rest; + + skipped.chars().for_each(|c| match c { + '\n' => { + self.current_line += 1; + self.current_col = 0; + } + _ => self.current_col += 1, + }); + + skipped + } + + /// advance forward for as long as whitespaces appear + fn skip_whitespaces(&mut self) { + let skip = self + .rest + .chars() + .map_while(|c| c.is_whitespace().then(|| c.len_utf8())) + .sum::(); + if skip > 0 { + self.advance(skip); + } + } + + #[inline(always)] + fn loc(&self) -> (usize, usize) { + (self.current_line, self.current_col) + } + + fn span(&self, (start_line, start_col): (usize, usize)) -> Span { + Span { + start_line, + start_col, + end_line: self.current_line, + end_col: self.current_col, + } + } + + fn eat_identifier(&mut self) -> Result<(Token<'s>, Span), Error> { + let ident_len = lex_identifier(self.rest); + if ident_len > 0 { + let old_loc = self.loc(); + let ident = self.advance(ident_len); + let token = if let Some(b'$') = ident.as_bytes().first() { + Token::SysVarIdent(&ident[1..]) + } else { + Token::VarIdent(ident) + }; + + Ok((token, self.span(old_loc))) + } else { + Err(self.syntax_error("unexpected character")) + } + } + + fn syntax_error(&mut self, msg: &'static str) -> Error { + self.failed = true; + anyhow!(msg) + // Error::new(ErrorKind::SyntaxError, msg) + } +} + +fn lex_identifier(s: &str) -> usize { + s.chars() + .enumerate() + .map_while(|(idx, c)| { + let cont = if c == '_' || c == '$' || c == '-' { + true + } else if idx == 0 { + unicode_ident::is_xid_start(c) + } else { + unicode_ident::is_xid_continue(c) + }; + cont.then(|| c.len_utf8()) + }) + .sum::() +} + +fn memchr(haystack: &[u8], needle: u8) -> Option { + haystack.iter().position(|&x| x == needle) +} + +#[inline(always)] +fn find_marker(a: &str) -> Option { + let bytes = a.as_bytes(); + let mut offset = 0; + loop { + if let Some(idx) = memchr(&bytes[offset..], b'{') { + if let Some(b'{') = bytes.get(offset + idx + 1).copied() { + // this prevents the `${{` situation + if let Some(b'$') = bytes.get(offset + idx - 1) { + break None; + } else { + break Some(offset + idx); + } + } + offset += idx + 1; + } else { + break None; + } + } +} + +pub fn tokenize(input: &str) -> impl Iterator, Span), Error>> { + let mut state = TokenizerState { + rest: input, + stack: vec![LexerState::Template], + failed: false, + current_line: 1, + current_col: 0, + }; + + std::iter::from_fn(move || loop { + if state.rest.is_empty() || state.failed { + return None; + } + + let prev_loc = state.loc(); + match state.stack.last() { + Some(LexerState::Template) => { + if let Some("{{") = state.rest.get(..2) { + // entering the `InVariable` state + state.advance(2); + state.stack.push(LexerState::InVariable); + return Some(Ok((Token::VariableStart, state.span(prev_loc)))); + } + + let (lead, span) = match find_marker(state.rest) { + Some(start) => (state.advance(start), state.span(prev_loc)), + None => (state.advance(state.rest.len()), state.span(prev_loc)), + }; + + if lead.is_empty().not() { + return Some(Ok((Token::TemplateData(lead), span))); + } + } + Some(LexerState::InVariable) => { + state.skip_whitespaces(); + + if let Some("}}") = state.rest.get(..2) { + state.stack.pop(); + state.advance(2); + return Some(Ok((Token::VariableEnd, state.span(prev_loc)))); + } + + return Some(state.eat_identifier()); + } + None => todo!("lexer state is empty!?"), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_template_data() { + match tokenize("hello {{ world }}").next() { + Some(Ok((Token::TemplateData(data), _))) if data == "hello " => {} + s => panic!("did not get a matching token result: {:?}", s), + } + } + + #[test] + fn test_template_data_with_dollar_2braces() { + match tokenize("hello ${{ world }}").next() { + Some(Ok((Token::TemplateData(data), _))) if data == "hello ${{ world }}" => {} + s => panic!("did not get a matching token result: {:?}", s), + } + } + + #[test] + fn test_tokenize_var_ident() { + let mut tokens = tokenize("hello {{ world }}").skip(1); + + assert_eq!(tokens.next().unwrap().unwrap().0, Token::VariableStart); + + match tokens.next() { + Some(Ok((Token::VarIdent(id), _))) if id == "world" => {} + s => panic!("did not get a matching token result: {:?}", s), + } + + assert_eq!(tokens.next().unwrap().unwrap().0, Token::VariableEnd); + assert!(tokens.next().is_none()) + } + + #[test] + fn test_tokenize_var_ident_containing_dash() { + let mut tokens = tokenize("hello {{ new-world }}").skip(2); + + match tokens.next() { + Some(Ok((Token::VarIdent(id), _))) if id == "new-world" => {} + s => panic!("did not get a matching token result: {:?}", s), + } + + assert_eq!(tokens.next().unwrap().unwrap().0, Token::VariableEnd); + assert!(tokens.next().is_none()) + } + + #[test] + fn test_tokenize_sys_var_ident() { + let mut tokens = tokenize("hello {{ $world }}").skip(2); + + match tokens.next() { + Some(Ok((Token::SysVarIdent(id), _))) if id == "world" => {} + s => panic!("did not get a matching token result: {:?}", s), + } + + assert_eq!(tokens.next().unwrap().unwrap().0, Token::VariableEnd); + } + + #[test] + fn test_tokenize_sys_var_ident_with_a_argument() { + let mut tokens = tokenize("hello {{ $processEnv envVarName }}").skip(2); + + match tokens.next() { + Some(Ok((Token::SysVarIdent(id), _))) if id == "processEnv" => {} + s => panic!("did not get a matching token result: {:?}", s), + } + + match tokens.next() { + Some(Ok((Token::VarIdent(id), _))) if id == "envVarName" => {} + s => panic!("did not get a matching token result: {:?}", s), + } + + assert_eq!(tokens.next().unwrap().unwrap().0, Token::VariableEnd); + } +} diff --git a/curlz/src/curlz/templ-lang/lib.rs b/curlz/src/curlz/templ-lang/lib.rs new file mode 100644 index 0000000..7d12d9a --- /dev/null +++ b/curlz/src/curlz/templ-lang/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: usize, right: usize) -> usize { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/curlz/src/curlz/templ-lang/mod.rs b/curlz/src/curlz/templ-lang/mod.rs new file mode 100644 index 0000000..0a72d6a --- /dev/null +++ b/curlz/src/curlz/templ-lang/mod.rs @@ -0,0 +1,6 @@ +mod ast; +mod ast_visitor; +mod lexer; +mod parser; +mod runtime; +mod tokens; diff --git a/curlz/src/curlz/templ-lang/parser.rs b/curlz/src/curlz/templ-lang/parser.rs new file mode 100644 index 0000000..929d1be --- /dev/null +++ b/curlz/src/curlz/templ-lang/parser.rs @@ -0,0 +1,154 @@ +use crate::language::ast; +use crate::language::ast::Spanned; +use crate::language::lexer::tokenize; +use crate::language::tokens::{Span, Token}; +use anyhow::Error; +use toml::Value; + +pub struct Parser<'a> { + tokens: Box, Span), Error>> + 'a>, + current_token: Option, Span), Error>>, + last_span: Span, +} + +impl<'a> Parser<'a> { + fn expand_span(&self, mut span: Span) -> Span { + span.end_line = self.last_span.end_line; + span.end_col = self.last_span.end_col; + span + } +} + +impl<'a> Parser<'a> { + pub fn new(source: &'a str) -> Self { + let mut tokens = Box::new(tokenize(source)); + let current_token = tokens.next(); + let last_span = Default::default(); + + Self { + tokens, + current_token, + last_span, + } + } + + pub fn parse(&mut self) -> Result, Error> { + self.parse_template() + } + + fn parse_template(&mut self) -> Result, Error> { + let span = self.last_span; + Ok(ast::Stmt::Template(Spanned::new( + ast::Template { + children: { + let mut rv = Vec::new(); + while let Some(Ok((token, span))) = self.current_token.take() { + match token { + Token::TemplateData(raw) => rv + .push(ast::Stmt::EmitRaw(Spanned::new(ast::EmitRaw { raw }, span))), + Token::VariableStart => { + let expr = self.parse_expr()?; + rv.push(ast::Stmt::EmitExpr(Spanned::new( + ast::EmitExpr { expr }, + self.expand_span(span), + ))); + self.ensure_next_token(Token::VariableEnd); + } + _ => unreachable!("the lexer messed hard up"), + } + self.current_token = self.tokens.next(); + } + rv + }, + }, + self.expand_span(span), + ))) + } + + // todo: this could also be a macro: + // expect_token!(self, Token::VariableEnd, "end of variable block"); + fn ensure_next_token(&mut self, expected_token: Token) { + if let Some(Ok((token, span))) = self.tokens.next() { + if token != expected_token { + panic!("{expected_token} was not found at {span:?}"); + } + } else { + panic!("{expected_token} was not found at {:?}", self.last_span); + } + } + + fn parse_expr(&mut self) -> Result, Error> { + // todo: this would not remain here, ident is only the most simplest expression + self.parse_ident() + } + + fn parse_ident(&mut self) -> Result, Error> { + if let Some(Ok((token, span))) = self.tokens.next() { + match token { + Token::VarIdent("true" | "True") => Ok(ast::Expr::Const(Spanned::new( + ast::Const { + value: Value::Boolean(true), + }, + span, + ))), + Token::VarIdent(name) => { + Ok(ast::Expr::Var(Spanned::new(ast::Var { id: name }, span))) + } + Token::SysVarIdent(name) => { + let expr = ast::Expr::SysVar(Spanned::new(ast::SysVar { id: name }, span)); + match name { + // has one argument + "processEnv" => { + let arg = self.parse_ident()?; + Ok(ast::Expr::Call(Spanned::new( + ast::Call { + expr, + args: vec![arg], + }, + span, + ))) + } + &_ => Ok(expr), + } + } + _ => todo!(), + } + } else { + todo!() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(test)] + mod expr { + use super::*; + + #[test] + fn test_parse_boolean() { + let mut p = Parser::new("hello {{ true }}"); + insta::assert_debug_snapshot!(p.parse()); + } + + #[test] + fn test_parse_var() { + let mut p = Parser::new("hello {{ world }}"); + insta::assert_debug_snapshot!(p.parse()); + } + + #[test] + fn test_parse_sys_var() { + let mut p = Parser::new("hello {{ $HOME }}"); + insta::assert_debug_snapshot!(p.parse()); + } + + #[test] + fn test_parse_process_env() { + let mut p = Parser::new("hello {{ $processEnv HOME }}"); + insta::assert_debug_snapshot!(p.parse()); + } + } +} diff --git a/curlz/src/curlz/templ-lang/runtime.rs b/curlz/src/curlz/templ-lang/runtime.rs new file mode 100644 index 0000000..0968de4 --- /dev/null +++ b/curlz/src/curlz/templ-lang/runtime.rs @@ -0,0 +1,113 @@ +use crate::language::ast; +use crate::language::ast_visitor::{AstVisit, AstVisitAcceptor}; +use std::borrow::Cow; +use std::collections::HashMap; + +#[derive(Default)] +struct Runtime<'source> { + // todo: maybe an `Ident` should be the key for variables + vars: HashMap<&'source str, ast::Value>, + output: Vec, +} + +impl<'source> Runtime<'source> { + /// registers a variable with a given `id` that is the variable identifier + pub fn with_variable(mut self, id: &'source str, var: impl Into) -> Self { + self.vars.insert(id, var.into()); + + self + } + + /// returns the rendered template as a string in form of a `Cow<'_, str>` + pub fn rendered(&mut self) -> Cow<'_, str> { + String::from_utf8_lossy(self.output.as_slice()) + } + + #[cfg(test)] + pub fn render(&mut self, source: &'source str) -> Cow<'_, str> { + use crate::language::parser::Parser; + + let parsed = Parser::new(source).parse().unwrap(); + parsed.accept(self); + + self.rendered() + } +} + +impl<'source> AstVisit<'source> for Runtime<'source> { + fn visit_stmt(&mut self, stmt: &ast::Stmt<'source>) { + use ast::Stmt::*; + + match stmt { + Template(spanned) => { + for s in spanned.node.children.as_slice() { + s.accept(self); + } + } + EmitRaw(spanned) => spanned.node.accept(self), + EmitExpr(spanned) => spanned.node.expr.accept(self), + } + } + + fn visit_expr(&mut self, expr: &ast::Expr<'source>) { + use ast::Expr::*; + + match expr { + SysVar(_var) => todo!(), + Var(var) => { + if let Some(var) = self.vars.get(var.node.id) { + self.output + .extend_from_slice(var.as_str().unwrap().as_bytes()); + } + } + Const(_) => { + todo!() + } + Call(_) => { + todo!() + } + } + } + + fn visit_emit_raw(&mut self, raw: &ast::EmitRaw<'source>) { + self.output.extend_from_slice(raw.raw.as_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::language::ast::IntoSpanned; + + #[test] + fn test_expr_var() { + let mut runtime = Runtime::default().with_variable("foo", "John"); + let expr = ast::Expr::Var(ast::Var { id: "foo" }.spanned()); + + expr.accept(&mut runtime); + assert_eq!(runtime.rendered(), "John"); + } + + #[test] + fn test_expr_sys_var() { + assert_eq!( + Runtime::default().render("{{ $processEnv HOME }}"), + env!("HOME") + ); + } + + #[test] + fn test_whole_template() { + assert_eq!( + Runtime::default() + .with_variable("world", "John") + .render("hello {{ world }}"), + "hello John" + ); + } + + #[test] + fn test_whole_template_unhappy() { + assert_eq!(Runtime::default().render("hello {{ world }}"), "hello "); + } +} diff --git a/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_boolean.snap b/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_boolean.snap new file mode 100644 index 0000000..4f2f5a5 --- /dev/null +++ b/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_boolean.snap @@ -0,0 +1,40 @@ +--- +source: src/curlz/language/parser.rs +expression: p.parse() +--- +Ok( + Template( + Spanned { + node: Template { + children: [ + EmitRaw( + Spanned { + node: EmitRaw { + raw: "hello ", + }, + span: @ 1:0-1:6, + }, + ), + EmitExpr( + Spanned { + node: EmitExpr { + expr: Const( + Spanned { + node: Const { + value: Boolean( + true, + ), + }, + span: @ 1:9-1:13, + }, + ), + }, + span: @ 1:6-0:0, + }, + ), + ], + }, + span: @ 0:0-0:0, + }, + ), +) diff --git a/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_process_env.snap b/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_process_env.snap new file mode 100644 index 0000000..45cde2b --- /dev/null +++ b/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_process_env.snap @@ -0,0 +1,55 @@ +--- +source: src/curlz/language/parser.rs +expression: p.parse() +--- +Ok( + Template( + Spanned { + node: Template { + children: [ + EmitRaw( + Spanned { + node: EmitRaw { + raw: "hello ", + }, + span: @ 1:0-1:6, + }, + ), + EmitExpr( + Spanned { + node: EmitExpr { + expr: Call( + Spanned { + node: Call { + expr: SysVar( + Spanned { + node: SysVar { + id: "processEnv", + }, + span: @ 1:9-1:20, + }, + ), + args: [ + Var( + Spanned { + node: Var { + id: "HOME", + }, + span: @ 1:21-1:25, + }, + ), + ], + }, + span: @ 1:9-1:20, + }, + ), + }, + span: @ 1:6-0:0, + }, + ), + ], + }, + span: @ 0:0-0:0, + }, + ), +) diff --git a/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_sys_var.snap b/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_sys_var.snap new file mode 100644 index 0000000..bdde384 --- /dev/null +++ b/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_sys_var.snap @@ -0,0 +1,38 @@ +--- +source: src/curlz/language/parser.rs +expression: p.parse() +--- +Ok( + Template( + Spanned { + node: Template { + children: [ + EmitRaw( + Spanned { + node: EmitRaw { + raw: "hello ", + }, + span: @ 1:0-1:6, + }, + ), + EmitExpr( + Spanned { + node: EmitExpr { + expr: SysVar( + Spanned { + node: SysVar { + id: "HOME", + }, + span: @ 1:9-1:14, + }, + ), + }, + span: @ 1:6-0:0, + }, + ), + ], + }, + span: @ 0:0-0:0, + }, + ), +) diff --git a/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_var.snap b/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_var.snap new file mode 100644 index 0000000..6c84c7e --- /dev/null +++ b/curlz/src/curlz/templ-lang/snapshots/curlz__language__parser__tests__expr__parse_var.snap @@ -0,0 +1,38 @@ +--- +source: src/curlz/language/parser.rs +expression: p.parse() +--- +Ok( + Template( + Spanned { + node: Template { + children: [ + EmitRaw( + Spanned { + node: EmitRaw { + raw: "hello ", + }, + span: @ 1:0-1:6, + }, + ), + EmitExpr( + Spanned { + node: EmitExpr { + expr: Var( + Spanned { + node: Var { + id: "world", + }, + span: @ 1:9-1:14, + }, + ), + }, + span: @ 1:6-0:0, + }, + ), + ], + }, + span: @ 0:0-0:0, + }, + ), +) diff --git a/curlz/src/curlz/templ-lang/tokens.rs b/curlz/src/curlz/templ-lang/tokens.rs new file mode 100644 index 0000000..777fdcc --- /dev/null +++ b/curlz/src/curlz/templ-lang/tokens.rs @@ -0,0 +1,47 @@ +use std::fmt::{Debug, Display, Formatter}; + +/// Represents a Token +#[derive(PartialEq, Debug)] +pub enum Token<'a> { + /// Raw template data + TemplateData(&'a str), + /// Variable block starts after a "{{" + VariableStart, + /// Variable block end with a "}}" + VariableEnd, + /// An identifier for a variable + VarIdent(&'a str), + /// An identifier for a system variable + SysVarIdent(&'a str), +} + +impl<'a> Display for Token<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Token::TemplateData(_) => write!(f, "template-data"), + Token::VariableStart => write!(f, "start of variable block"), + Token::VariableEnd => write!(f, "end of variable block"), + Token::VarIdent(_) => write!(f, "variable identifier"), + Token::SysVarIdent(_) => write!(f, "system variable identifier"), + } + } +} + +/// Token span information +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub struct Span { + pub start_line: usize, + pub start_col: usize, + pub end_line: usize, + pub end_col: usize, +} + +impl Debug for Span { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + " @ {}:{}-{}:{}", + self.start_line, self.start_col, self.end_line, self.end_col + ) + } +}