diff --git a/.gitignore b/.gitignore index 7214e72..443a73c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /target -/test/template/*.gleam +/test/templates/*.gleam diff --git a/README.md b/README.md index f27afcc..661396d 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,51 @@ to use with the `with` syntax below to help Gleam check variables used in the te {> import my_user.{MyUser} ``` +### Functions + +You can use the `{> fn ... {> endfn` syntax to add a local function to your template: + +``` +{> fn full_name(second_name: String) +Lucy {{ second_name }} +{> endfn +``` + +The function always returns a `StringBuilder` value so you must use `{[ ... ]}` syntax to insert +them into templates. The function body has its last new line trimmed, so the above function called +as `full_name("Gleam")` would result in `Lucy Gleam` and not `\nLucy Gleam\n` or any other +variation. If you want a trailing new line in the output then add an extra blank line before the `{> endfn`. + +The function declaration has no impact on the final template as all lines are removed from the +final text. + +Like in normal code, functions make it easier to deal with repeated components within your template. + +``` +{> fn item(name: String) +
  • {{ name }}
  • +{> endfn + + +``` + +You can use the `pub` keyword to declare the function as public in which case other modules will be +able to import it from gleam module compiled from the template. + +``` +{> pub fn user_item(name: String) +
  • {{ name }}
  • +{> endfn +``` + +If a template only includes function declarations and no meaningful template content then matcha +will not add the `render` and `render_builder`. Instead the module will act as a library of +functions where each function body is a template. + ## Output A template like: diff --git a/src/error.rs b/src/error.rs index 731290d..6047b52 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,7 +7,7 @@ use crate::parser::ParserError; use crate::renderer::RenderError; use crate::scanner::{Range, ScanError, Token}; -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct Source { pub filename: String, pub contents: String, @@ -106,6 +106,12 @@ pub fn write(writer: &mut W, error: Error) { ParserError::UnexpectedEnd => { let _ = write!(writer, "Unexpected end"); } + ParserError::FunctionWithinStatement(range) => explain_with_source( + writer, + "Functions must be declared at the top level.", + source, + range, + ), }, Error::Render(error, source) => match error { RenderError::DuplicateParamName(name, range) => explain_with_source( @@ -220,4 +226,26 @@ mod test { Hello"# ); } + + #[test] + fn test_function_in_for_loop_error() { + assert_error!( + r#"{% for item in list %} +{> fn full_name(second_name: String) +Lucy {{ second_name }} +{> endfn +{% endfor %}"# + ); + } + + #[test] + fn test_public_function_in_for_loop_error() { + assert_error!( + r#"{% for item in list %} +{> pub fn full_name(second_name: String) +Lucy {{ second_name }} +{> endfn +{% endfor %}"# + ); + } } diff --git a/src/main.rs b/src/main.rs index 8b1c66b..4ee4644 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ fn convert(prog_name: &str, file_path: &std::path::Path) -> Result<(), ()> { let from_file_name = file_path .file_name() .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or(String::from("unknown")); + .unwrap_or_else(|| String::from("unknown")); let result = std::fs::read_to_string(file_path) .map_err(|err| Error::IO(err, file_path.to_path_buf())) @@ -89,7 +89,7 @@ fn main() { if opt.verbose { println!("Converting {}", path.display()); } - Some(convert(NAME, &path.to_path_buf())) + Some(convert(NAME, path)) } else { None } diff --git a/src/parser.rs b/src/parser.rs index 38bb1d5..a16bfc7 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -13,12 +13,20 @@ pub enum Node { For(String, Option, String, Vec), Import(String), With((String, Range), Type), + BlockFunction(Visibility, String, Vec, Range), } #[derive(Debug)] pub enum ParserError { UnexpectedToken(Token, Range, Vec), UnexpectedEnd, + FunctionWithinStatement(Range), +} + +#[derive(Debug)] +pub enum Visibility { + Public, + Private, } pub fn parse(tokens: &mut TokenIter) -> Result, ParserError> { @@ -27,6 +35,7 @@ pub fn parse(tokens: &mut TokenIter) -> Result, ParserError> { } fn parse_statement(tokens: &mut TokenIter) -> Result { + log::trace!("parse_statement"); match tokens.next() { Some((Token::If, _)) => parse_if_statement(tokens), Some((Token::For, _)) => parse_for_statement(tokens), @@ -51,12 +60,12 @@ fn parse_inner(tokens: &mut TokenIter, in_statement: bool) -> Result, Some((Token::OpenValue, _)) => { let (name, _) = extract_code(tokens)?; ast.push(Node::Identifier(name.clone())); - consume_token(tokens, Token::CloseValue)?; + consume_token(tokens, Token::CloseValue, false)?; } Some((Token::OpenBuilder, _)) => { let (name, _) = extract_code(tokens)?; ast.push(Node::Builder(name.clone())); - consume_token(tokens, Token::CloseBuilder)?; + consume_token(tokens, Token::CloseBuilder, false)?; } Some((Token::OpenStmt, _)) => { if let Some((Token::Else, _)) | Some((Token::EndIf, _)) | Some((Token::EndFor, _)) = @@ -81,6 +90,10 @@ fn parse_inner(tokens: &mut TokenIter, in_statement: bool) -> Result, ast.push(node); } Some((Token::OpenLine, _)) => { + if let Some((Token::EndFn, _)) = tokens.peek() { + break; + } + match tokens.next() { Some((Token::Import, _)) => { let import_details = extract_import_details(tokens)?; @@ -88,13 +101,35 @@ fn parse_inner(tokens: &mut TokenIter, in_statement: bool) -> Result, } Some((Token::With, _)) => { let (identifier, range) = extract_identifier(tokens)?; - consume_token(tokens, Token::As)?; + consume_token(tokens, Token::As, false)?; let (type_, _) = extract_identifier(tokens)?; ast.push(Node::With((identifier, range), type_)) } + Some((Token::Fn, range)) => { + if in_statement { + return Err(ParserError::FunctionWithinStatement(range.clone())); + } + + let node = parse_function(tokens, Visibility::Private)?; + ast.push(node); + } + Some((Token::Pub, pub_range)) => { + let fn_range = consume_token(tokens, Token::Fn, false)?; + let range = fn_range + .map(|range| Range { + start: std::cmp::min(range.start, pub_range.start), + end: std::cmp::max(range.end, pub_range.end), + }) + .unwrap_or_else(|| pub_range.clone()); + if in_statement { + return Err(ParserError::FunctionWithinStatement(range)); + } + let node = parse_function(tokens, Visibility::Public)?; + ast.push(node); + } _ => {} } - consume_token(tokens, Token::CloseLine)?; + consume_token(tokens, Token::CloseLine, false)?; } Some((token, range)) => { return Err(ParserError::UnexpectedToken( @@ -112,24 +147,34 @@ fn parse_inner(tokens: &mut TokenIter, in_statement: bool) -> Result, Ok(ast) } +fn parse_function(tokens: &mut TokenIter, visibility: Visibility) -> Result { + let (head, range) = extract_code(tokens)?; + consume_token(tokens, Token::CloseLine, false)?; + let body = parse_inner(tokens, true)?; + let body = trim_trailing_newline(body); + consume_token(tokens, Token::EndFn, false)?; + + Ok(Node::BlockFunction(visibility, head, body, range)) +} + fn parse_if_statement(tokens: &mut TokenIter) -> Result { log::trace!("parse_if_statement"); let (name, _) = extract_code(tokens)?; - consume_token(tokens, Token::CloseStmt)?; + consume_token(tokens, Token::CloseStmt, false)?; let if_nodes = parse_inner(tokens, true)?; let mut else_nodes = vec![]; match tokens.next() { Some((Token::EndIf, _)) => { - consume_token(tokens, Token::CloseStmt)?; + consume_token(tokens, Token::CloseStmt, false)?; } Some((Token::Else, _)) => { - consume_token(tokens, Token::CloseStmt)?; + consume_token(tokens, Token::CloseStmt, false)?; else_nodes = parse_inner(tokens, true)?; - consume_token(tokens, Token::EndIf)?; - consume_token(tokens, Token::CloseStmt)?; + consume_token(tokens, Token::EndIf, false)?; + consume_token(tokens, Token::CloseStmt, false)?; } Some((token, range)) => { return Err(ParserError::UnexpectedToken( @@ -147,11 +192,12 @@ fn parse_if_statement(tokens: &mut TokenIter) -> Result { } fn parse_for_statement(tokens: &mut TokenIter) -> Result { + log::trace!("parse_for_statement"); let (entry_identifier, _) = extract_identifier(tokens)?; let entry_type = match tokens.next() { Some((Token::As, _)) => { let (type_identifier, _) = extract_identifier(tokens)?; - consume_token(tokens, Token::In)?; + consume_token(tokens, Token::In, false)?; Some(type_identifier) } Some((Token::In, _)) => None, @@ -166,12 +212,12 @@ fn parse_for_statement(tokens: &mut TokenIter) -> Result { }; let (list_identifier, _) = extract_code(tokens)?; - consume_token(tokens, Token::CloseStmt)?; + consume_token(tokens, Token::CloseStmt, false)?; let loop_nodes = parse_inner(tokens, true)?; - consume_token(tokens, Token::EndFor)?; - consume_token(tokens, Token::CloseStmt)?; + consume_token(tokens, Token::EndFor, false)?; + consume_token(tokens, Token::CloseStmt, false)?; Ok(Node::For( entry_identifier, @@ -222,6 +268,7 @@ fn extract_code(tokens: &mut TokenIter) -> Result<(String, Range), ParserError> Some((Token::CloseStmt, _)) => break, Some((Token::CloseValue, _)) => break, Some((Token::CloseBuilder, _)) => break, + Some((Token::CloseLine, _)) => break, Some((token, range)) => { if code.is_empty() { return Err(ParserError::UnexpectedToken( @@ -253,19 +300,64 @@ fn extract_import_details(tokens: &mut TokenIter) -> Result } } -fn consume_token(tokens: &mut TokenIter, expected_token: Token) -> Result<(), ParserError> { - log::trace!("consume_token"); +fn consume_token( + tokens: &mut TokenIter, + expected_token: Token, + accept_end: bool, +) -> Result, ParserError> { + log::trace!("consume_token: {:?}", expected_token); match tokens.next() { - Some((matched_token, _)) if *matched_token == expected_token => Ok(()), + Some((matched_token, range)) if *matched_token == expected_token => Ok(Some(range.clone())), Some((matched_token, range)) => Err(ParserError::UnexpectedToken( matched_token.clone(), range.clone(), vec![expected_token], )), - None => Err(ParserError::UnexpectedEnd), + None => { + if accept_end { + Ok(None) + } else { + log::error!( + "consume_token - found: None, expected_token: {:?}", + expected_token + ); + Err(ParserError::UnexpectedEnd) + } + } } } +/// Find the last item in the nodes and if it is a Text node then trim the final '\n' from it. If +/// it is just a '\n' then drop the node entirely +fn trim_trailing_newline(nodes: Vec) -> Vec { + let length = nodes.len(); + nodes + .into_iter() + .enumerate() + .flat_map(|(i, node)| { + if i == length - 1 { + match node { + Node::Text(text) => { + if text == "\n" { + None + } else { + Some(Node::Text( + text.strip_suffix('\n') + .map(String::from) + .unwrap_or_else(|| text.to_string()), + )) + } + } + + node => Some(node), + } + } else { + Some(node) + } + }) + .collect() +} + #[cfg(test)] mod test { use std::fmt::Debug; @@ -397,4 +489,29 @@ mod test { fn test_parse_builder_expression() { assert_parse!("Hello {[ string_builder.from_strings([\"Anna\", \" and \", \"Bob\"]) ]}, good to meet you"); } + + #[test] + fn test_parse_function() { + assert_parse!("{> fn classes()\na b c d\n{> endfn\n"); + } + + #[test] + fn test_parse_function_with_trailing_new_line() { + assert_parse!("{> fn classes()\na b c d\n\n{> endfn\n"); + } + + #[test] + fn test_parse_public_function() { + assert_parse!("{> pub fn classes()\na b c d\n{> endfn\n"); + } + + #[test] + fn test_parse_function_with_arg_and_usage() { + assert_parse!( + r#"{> fn full_name(second_name: String) +Lucy {{ second_name }} +{> endfn +Hello {[ full_name("Gleam") ]}"# + ); + } } diff --git a/src/renderer.rs b/src/renderer.rs index af0fcb4..3662c03 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,4 +1,4 @@ -use crate::parser::Node; +use crate::parser::{Node, Visibility}; use crate::scanner::Range; type NodeIter<'a> = std::iter::Peekable>; @@ -8,44 +8,59 @@ pub enum RenderError { DuplicateParamName(String, Range), } +#[derive(Debug)] +struct Context { + pub builder_lines: String, + pub imports: Vec, + pub functions: Vec, + pub typed_params: Vec<(String, String)>, + pub includes_for_loop: bool, + pub has_template_content: bool, +} + pub fn render( iter: &mut NodeIter, prog_name: &str, from_file_name: &str, ) -> Result { - let (builder_lines, imports, typed_params, includes_for_loop) = render_lines(iter)?; + let context = render_lines(iter)?; - let import_lines = imports + let import_lines = context + .imports .iter() .map(|details| format!("import {}", details)) .collect::>() .join("\n"); - let params_string = typed_params + let params_string = context + .typed_params .iter() .map(|(param_name, type_name)| format!("{} {}: {}", param_name, param_name, type_name)) .collect::>() .join(", "); - let args_string = typed_params + let args_string = context + .typed_params .iter() .map(|(param_name, _)| format!("{}: {}", param_name, param_name)) .collect::>() .join(", "); - let list_import = if includes_for_loop { + let functions = if context.functions.is_empty() { + String::new() + } else { + context.functions.join("\n\n") + }; + + let list_import = if context.includes_for_loop { "import gleam/list\n" } else { "" }; - let output = format!( - r#"// DO NOT EDIT: Code generated by {prog_name} from {source_file} - -import gleam/string_builder.{{StringBuilder}} -{list_import} -{import_lines} - + let render_functions = if context.has_template_content { + format!( + r#" pub fn render_builder({params_string}) -> StringBuilder {{ let builder = string_builder.from_string("") {builder_lines} @@ -55,30 +70,44 @@ pub fn render_builder({params_string}) -> StringBuilder {{ pub fn render({params_string}) -> String {{ string_builder.to_string(render_builder({args_string})) }} +"#, + params_string = params_string, + builder_lines = context.builder_lines, + args_string = args_string + ) + } else { + String::new() + }; + + let output = format!( + r#"// DO NOT EDIT: Code generated by {prog_name} from {source_file} + +import gleam/string_builder.{{StringBuilder}} +{list_import} +{import_lines}{functions} +{render_functions} "#, prog_name = prog_name, source_file = from_file_name, list_import = list_import, import_lines = import_lines, - params_string = params_string, - builder_lines = builder_lines, - args_string = args_string + render_functions = render_functions, ); Ok(output) } -type RenderDetails = (String, Vec, Vec<(String, String)>, bool); - -fn render_lines(iter: &mut NodeIter) -> Result { +fn render_lines(iter: &mut NodeIter) -> Result { let mut builder_lines = String::new(); let mut imports = vec![]; + let mut functions = vec![]; // Use a Vec<(String, String)> instead of a HashMap to maintain order which gives the users // some control, though parameters are labelled and can be called in any order. Some kind of // order is required to keep the tests passing as it seems to be non-determinate in a HashMap let mut typed_params = Vec::new(); let mut includes_for_loop = false; + let mut has_template_content = false; loop { match iter.peek() { @@ -86,8 +115,12 @@ fn render_lines(iter: &mut NodeIter) -> Result { iter.next(); builder_lines.push_str(&format!( " let builder = string_builder.append(builder, \"{}\")\n", - text.replace("\"", "\\\"") + text.replace('\"', "\\\"") )); + + // We have some kind of content if the text is not only whitespace. We don't need + // to handle this recursively as we're only interested in the top level. + has_template_content = has_template_content || !text.trim().is_empty(); } Some(Node::Identifier(name)) => { iter.next(); @@ -95,6 +128,7 @@ fn render_lines(iter: &mut NodeIter) -> Result { " let builder = string_builder.append(builder, {})\n", name )); + has_template_content = true; } Some(Node::Builder(name)) => { iter.next(); @@ -102,6 +136,7 @@ fn render_lines(iter: &mut NodeIter) -> Result { " let builder = string_builder.append_builder(builder, {})\n", name )); + has_template_content = true; } Some(Node::Import(import_details)) => { iter.next(); @@ -118,13 +153,12 @@ fn render_lines(iter: &mut NodeIter) -> Result { } typed_params.push((identifier.clone(), type_.clone())); + has_template_content = true; } Some(Node::If(identifier_name, if_nodes, else_nodes)) => { iter.next(); - let (if_lines, _, _, if_block_includes_for_loop) = - render_lines(&mut if_nodes.iter().peekable())?; - let (else_lines, _, _, else_block_includes_for_loop) = - render_lines(&mut else_nodes.iter().peekable())?; + let if_context = render_lines(&mut if_nodes.iter().peekable())?; + let else_context = render_lines(&mut else_nodes.iter().peekable())?; builder_lines.push_str(&format!( r#" let builder = case {} {{ True -> {{ @@ -137,10 +171,12 @@ fn render_lines(iter: &mut NodeIter) -> Result { }} }} "#, - identifier_name, if_lines, else_lines + identifier_name, if_context.builder_lines, else_context.builder_lines )); - includes_for_loop = - includes_for_loop || if_block_includes_for_loop || else_block_includes_for_loop; + includes_for_loop = includes_for_loop + || if_context.includes_for_loop + || else_context.includes_for_loop; + has_template_content = true; } Some(Node::For(entry_identifier, entry_type, list_identifier, loop_nodes)) => { iter.next(); @@ -150,23 +186,49 @@ fn render_lines(iter: &mut NodeIter) -> Result { .map(|value| format!(": {}", value)) .unwrap_or_else(|| "".to_string()); - let (loop_lines, _, _, _) = render_lines(&mut loop_nodes.iter().peekable())?; + let loop_context = render_lines(&mut loop_nodes.iter().peekable())?; builder_lines.push_str(&format!( r#" let builder = list.fold({}, builder, fn(builder, {}{}) {{ {} builder }}) "#, - list_identifier, entry_identifier, entry_type, loop_lines + list_identifier, entry_identifier, entry_type, loop_context.builder_lines )); includes_for_loop = true; + has_template_content = true; + } + Some(Node::BlockFunction(visiblity, head, body_nodes, _range)) => { + iter.next(); + let visibility_text = match visiblity { + Visibility::Private => "", + Visibility::Public => "pub ", + }; + let body_context = render_lines(&mut body_nodes.iter().peekable())?; + let body = body_context.builder_lines; + functions.push(format!( + r#"{visibility_text}fn {head} -> StringBuilder {{ + let builder = string_builder.from_string("") +{body} + builder +}}"#, + )); + + includes_for_loop = includes_for_loop || body_context.includes_for_loop; } None => break, } } - Ok((builder_lines, imports, typed_params, includes_for_loop)) + Ok(Context { + builder_lines, + imports, + functions, + typed_params, + includes_for_loop, + has_template_content, + }) } #[cfg(test)] @@ -363,4 +425,54 @@ Hello {[ name ]}, good to meet you" fn test_render_builder_expression() { assert_render!("Hello {[ string_builder.from_strings([\"Anna\", \" and \", \"Bob\"]) ]}, good to meet you"); } + + #[test] + fn test_render_function() { + assert_render!("{> fn classes()\na b c d\n{> endfn\nHello world"); + } + + #[test] + fn test_render_public_function() { + assert_render!("{> pub fn classes()\na b c d\n{> endfn\nHello world"); + } + + #[test] + fn test_render_only_public_functions() { + assert_render!( + r#" +{> pub fn classes() + a b c d +{> endfn + +{> pub fn item(name: String) +
  • {{ name }}
  • +{> endfn +"# + ); + } + + #[test] + fn test_render_function_and_usage() { + assert_render!("{> fn name()\nLucy\n{> endfn\nHello {[ name() ]}"); + } + + #[test] + fn test_render_function_with_arg_and_usage() { + assert_render!( + r#"{> fn full_name(second_name: String) +Lucy {{ second_name }} +{> endfn +Hello {[ full_name("Gleam") ]}"# + ); + } + + #[test] + fn test_render_function_with_for_loop() { + assert_render!( + r#"{> fn full_name(names: List(String)) +{% for name in names %}{{ name }},{% endfor %}" +{> endfn +Hello {[ names("Gleam") ]}"# + ); + } } diff --git a/src/scanner.rs b/src/scanner.rs index 1a3943f..bc849d1 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -5,7 +5,7 @@ pub type Position = usize; type Iter<'a> = std::iter::Peekable>; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Token { Text(String), OpenLine, @@ -27,6 +27,9 @@ pub enum Token { For, EndFor, In, + Fn, + EndFn, + Pub, } impl std::fmt::Display for Token { @@ -52,6 +55,9 @@ impl std::fmt::Display for Token { Token::For => "for", Token::EndFor => "endfor", Token::In => "in", + Token::Fn => "fn", + Token::EndFn => "endfn", + Token::Pub => "pub", }; write!(f, "{}", str) } @@ -156,6 +162,17 @@ fn scan_plain(iter: &mut Iter, mut tokens: Tokens) -> Result matches!(first, Some((_, "]"))) && matches!(second, Some((_, "}"))) })?; } else if let Some((second_index, ">")) = iter.peek() { + if !buffer.is_empty() { + tokens.push(( + Token::Text(buffer), + Range { + start: buffer_start_index.unwrap_or(0), + end: first_index, + }, + )); + buffer = String::new(); + } + tokens.push(( Token::OpenLine, Range { @@ -163,11 +180,12 @@ fn scan_plain(iter: &mut Iter, mut tokens: Tokens) -> Result end: *second_index, }, )); + iter.next(); tokens = scan_line(iter, tokens)?; - let range = consume_grapheme(iter, "\n")?; + let range = consume_grapheme(iter, "\n", true)?; tokens.push((Token::CloseLine, range)); } else { buffer.push('{'); @@ -321,6 +339,9 @@ fn to_token(identifier: &str) -> Token { "import" => Token::Import, "with" => Token::With, "as" => Token::As, + "fn" => Token::Fn, + "endfn" => Token::EndFn, + "pub" => Token::Pub, other => Token::IdentifierOrGleamToken(other.to_string()), } } @@ -380,13 +401,15 @@ fn scan_import_details(iter: &mut Iter) -> (String, Range) { ) } -fn consume_grapheme(iter: &mut Iter, expected: &str) -> Result { +fn consume_grapheme(iter: &mut Iter, expected: &str, accept_end: bool) -> Result { log::trace!("consume_grapheme"); match iter.next() { Some((index, grapheme)) if grapheme == expected => Ok(Range { start: index, end: index, }), + // TODO: Fix range for end of stream + None if accept_end => Ok(Range { start: 0, end: 0 }), entry => Err(entry .map(|(index, value)| ScanError::UnexpectedGrapheme(value.to_string(), index)) .unwrap_or(ScanError::UnexpectedEnd)), @@ -513,4 +536,29 @@ mod test { fn test_scan_builder_expression() { assert_scan!("Hello {[ string_builder.from_strings([\"Anna\", \" and \", \"Bob\"]) ]}, good to meet you"); } + + #[test] + fn test_scan_function_start() { + assert_scan!("{> fn concat(x: String, y: String) -> String\n"); + } + + #[test] + fn test_scan_function_end() { + assert_scan!("{> endfn\n"); + } + + #[test] + fn test_scan_function_end_eof() { + assert_scan!("{> endfn"); + } + + #[test] + fn test_scan_function_whole() { + assert_scan!("{> fn classes() -> String\nx y\n{> endfn\n"); + } + + #[test] + fn test_scan_pub_function_whole() { + assert_scan!("{> pub fn classes() -> String\nx y\n{> endfn\n"); + } } diff --git a/src/snapshots/matcha__error__test__function_in_for_loop_error.snap b/src/snapshots/matcha__error__test__function_in_for_loop_error.snap new file mode 100644 index 0000000..9ae19ba --- /dev/null +++ b/src/snapshots/matcha__error__test__function_in_for_loop_error.snap @@ -0,0 +1,13 @@ +--- +source: src/error.rs +expression: "{% for item in list %}\n{> fn full_name(second_name: String)\nLucy {{ second_name }}\n{> endfn\n{% endfor %}" +--- +Functions must be declared at the top level. + +error: + ┌─ -test-:2:4 + │ +2 │ {> fn full_name(second_name: String) + │ ^^ + + diff --git a/src/snapshots/matcha__error__test__public_function_in_for_loop_error.snap b/src/snapshots/matcha__error__test__public_function_in_for_loop_error.snap new file mode 100644 index 0000000..5cd2fce --- /dev/null +++ b/src/snapshots/matcha__error__test__public_function_in_for_loop_error.snap @@ -0,0 +1,13 @@ +--- +source: src/error.rs +expression: "{% for item in list %}\n{> pub fn full_name(second_name: String)\nLucy {{ second_name }}\n{> endfn\n{% endfor %}" +--- +Functions must be declared at the top level. + +error: + ┌─ -test-:2:4 + │ +2 │ {> pub fn full_name(second_name: String) + │ ^^^^^^ + + diff --git a/src/snapshots/matcha__parser__test__parse_function.snap b/src/snapshots/matcha__parser__test__parse_function.snap new file mode 100644 index 0000000..f2140e2 --- /dev/null +++ b/src/snapshots/matcha__parser__test__parse_function.snap @@ -0,0 +1,16 @@ +--- +source: src/parser.rs +expression: "{> fn classes()\na b c d\n{> endfn\n" +--- +[ + BlockFunction( + Private, + "classes()", + [ + Text( + "a b c d", + ), + ], + 0..0, + ), +] diff --git a/src/snapshots/matcha__parser__test__parse_function_with_arg_and_usage.snap b/src/snapshots/matcha__parser__test__parse_function_with_arg_and_usage.snap new file mode 100644 index 0000000..2fc892c --- /dev/null +++ b/src/snapshots/matcha__parser__test__parse_function_with_arg_and_usage.snap @@ -0,0 +1,25 @@ +--- +source: src/parser.rs +expression: "{> fn full_name(second_name: String)\nLucy {{ second_name }}\n{> endfn\nHello {[ full_name(\"Gleam\") ]}" +--- +[ + BlockFunction( + Private, + "full_name(second_name: String)", + [ + Text( + "Lucy ", + ), + Identifier( + "second_name", + ), + ], + 0..0, + ), + Text( + "Hello ", + ), + Builder( + "full_name(\"Gleam\")", + ), +] diff --git a/src/snapshots/matcha__parser__test__parse_function_with_trailing_new_line.snap b/src/snapshots/matcha__parser__test__parse_function_with_trailing_new_line.snap new file mode 100644 index 0000000..7b30927 --- /dev/null +++ b/src/snapshots/matcha__parser__test__parse_function_with_trailing_new_line.snap @@ -0,0 +1,16 @@ +--- +source: src/parser.rs +expression: "{> fn classes()\na b c d\n\n{> endfn\n" +--- +[ + BlockFunction( + Private, + "classes()", + [ + Text( + "a b c d\n", + ), + ], + 0..0, + ), +] diff --git a/src/snapshots/matcha__parser__test__parse_public_function.snap b/src/snapshots/matcha__parser__test__parse_public_function.snap new file mode 100644 index 0000000..3c0604b --- /dev/null +++ b/src/snapshots/matcha__parser__test__parse_public_function.snap @@ -0,0 +1,16 @@ +--- +source: src/parser.rs +expression: "{> pub fn classes()\na b c d\n{> endfn\n" +--- +[ + BlockFunction( + Public, + "classes()", + [ + Text( + "a b c d", + ), + ], + 0..0, + ), +] diff --git a/src/snapshots/matcha__renderer__test__render_function.snap b/src/snapshots/matcha__renderer__test__render_function.snap new file mode 100644 index 0000000..c85b1f8 --- /dev/null +++ b/src/snapshots/matcha__renderer__test__render_function.snap @@ -0,0 +1,26 @@ +--- +source: src/renderer.rs +expression: "{> fn classes()\na b c d\n{> endfn\nHello world" +--- +// DO NOT EDIT: Code generated by matcha from -test- + +import gleam/string_builder.{StringBuilder} + +fn classes() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "a b c d") + + builder +} + +pub fn render_builder() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "Hello world") + + builder +} + +pub fn render() -> String { + string_builder.to_string(render_builder()) +} + diff --git a/src/snapshots/matcha__renderer__test__render_function_and_usage.snap b/src/snapshots/matcha__renderer__test__render_function_and_usage.snap new file mode 100644 index 0000000..ed0d719 --- /dev/null +++ b/src/snapshots/matcha__renderer__test__render_function_and_usage.snap @@ -0,0 +1,27 @@ +--- +source: src/renderer.rs +expression: "{> fn name()\nLucy\n{> endfn\nHello {[ name() ]}" +--- +// DO NOT EDIT: Code generated by matcha from -test- + +import gleam/string_builder.{StringBuilder} + +fn name() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "Lucy") + + builder +} + +pub fn render_builder() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "Hello ") + let builder = string_builder.append_builder(builder, name()) + + builder +} + +pub fn render() -> String { + string_builder.to_string(render_builder()) +} + diff --git a/src/snapshots/matcha__renderer__test__render_function_with_arg_and_usage.snap b/src/snapshots/matcha__renderer__test__render_function_with_arg_and_usage.snap new file mode 100644 index 0000000..a2818a9 --- /dev/null +++ b/src/snapshots/matcha__renderer__test__render_function_with_arg_and_usage.snap @@ -0,0 +1,28 @@ +--- +source: src/renderer.rs +expression: "{> fn full_name(second_name: String)\nLucy {{ second_name }}\n{> endfn\nHello {[ full_name(\"Gleam\") ]}" +--- +// DO NOT EDIT: Code generated by matcha from -test- + +import gleam/string_builder.{StringBuilder} + +fn full_name(second_name: String) -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "Lucy ") + let builder = string_builder.append(builder, second_name) + + builder +} + +pub fn render_builder() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "Hello ") + let builder = string_builder.append_builder(builder, full_name("Gleam")) + + builder +} + +pub fn render() -> String { + string_builder.to_string(render_builder()) +} + diff --git a/src/snapshots/matcha__renderer__test__render_function_with_for_loop.snap b/src/snapshots/matcha__renderer__test__render_function_with_for_loop.snap new file mode 100644 index 0000000..903f509 --- /dev/null +++ b/src/snapshots/matcha__renderer__test__render_function_with_for_loop.snap @@ -0,0 +1,35 @@ +--- +source: src/renderer.rs +expression: "{> fn full_name(names: List(String))\n{% for name in names %}{{ name }},{% endfor %}\"\n{> endfn\nHello {[ names(\"Gleam\") ]}" +--- +// DO NOT EDIT: Code generated by matcha from -test- + +import gleam/string_builder.{StringBuilder} +import gleam/list + +fn full_name(names: List(String)) -> StringBuilder { + let builder = string_builder.from_string("") + let builder = list.fold(names, builder, fn(builder, name) { + let builder = string_builder.append(builder, name) + let builder = string_builder.append(builder, ",") + + builder +}) + let builder = string_builder.append(builder, "\"") + + builder +} + +pub fn render_builder() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "Hello ") + let builder = string_builder.append_builder(builder, names("Gleam")) + + builder +} + +pub fn render() -> String { + string_builder.to_string(render_builder()) +} + + diff --git a/src/snapshots/matcha__renderer__test__render_only_public_functions.snap b/src/snapshots/matcha__renderer__test__render_only_public_functions.snap new file mode 100644 index 0000000..b8a2928 --- /dev/null +++ b/src/snapshots/matcha__renderer__test__render_only_public_functions.snap @@ -0,0 +1,25 @@ +--- +source: src/renderer.rs +expression: "\n{> pub fn classes()\n a b c d\n{> endfn\n\n{> pub fn item(name: String)\n
  • {{ name }}
  • \n{> endfn\n" +--- +// DO NOT EDIT: Code generated by matcha from -test- + +import gleam/string_builder.{StringBuilder} + +pub fn classes() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, " a b c d") + + builder +} + +pub fn item(name: String) -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "
  • ") + let builder = string_builder.append(builder, name) + let builder = string_builder.append(builder, "
  • ") + + builder +} + + diff --git a/src/snapshots/matcha__renderer__test__render_public_function.snap b/src/snapshots/matcha__renderer__test__render_public_function.snap new file mode 100644 index 0000000..c81a75c --- /dev/null +++ b/src/snapshots/matcha__renderer__test__render_public_function.snap @@ -0,0 +1,26 @@ +--- +source: src/renderer.rs +expression: "{> pub fn classes()\na b c d\n{> endfn\nHello world" +--- +// DO NOT EDIT: Code generated by matcha from -test- + +import gleam/string_builder.{StringBuilder} + +pub fn classes() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "a b c d") + + builder +} + +pub fn render_builder() -> StringBuilder { + let builder = string_builder.from_string("") + let builder = string_builder.append(builder, "Hello world") + + builder +} + +pub fn render() -> String { + string_builder.to_string(render_builder()) +} + diff --git a/src/snapshots/matcha__scanner__test__scan_function_end.snap b/src/snapshots/matcha__scanner__test__scan_function_end.snap new file mode 100644 index 0000000..e4cd686 --- /dev/null +++ b/src/snapshots/matcha__scanner__test__scan_function_end.snap @@ -0,0 +1,18 @@ +--- +source: src/scanner.rs +expression: "{> endfn\n" +--- +[ + ( + OpenLine, + 0..1, + ), + ( + EndFn, + 3..8, + ), + ( + CloseLine, + 8..8, + ), +] diff --git a/src/snapshots/matcha__scanner__test__scan_function_end_eof.snap b/src/snapshots/matcha__scanner__test__scan_function_end_eof.snap new file mode 100644 index 0000000..feb64ab --- /dev/null +++ b/src/snapshots/matcha__scanner__test__scan_function_end_eof.snap @@ -0,0 +1,18 @@ +--- +source: src/scanner.rs +expression: "{> endfn" +--- +[ + ( + OpenLine, + 0..1, + ), + ( + EndFn, + 3..8, + ), + ( + CloseLine, + 0..0, + ), +] diff --git a/src/snapshots/matcha__scanner__test__scan_function_start.snap b/src/snapshots/matcha__scanner__test__scan_function_start.snap new file mode 100644 index 0000000..98b6ab8 --- /dev/null +++ b/src/snapshots/matcha__scanner__test__scan_function_start.snap @@ -0,0 +1,54 @@ +--- +source: src/scanner.rs +expression: "{> fn concat(x: String, y: String) -> String\n" +--- +[ + ( + OpenLine, + 0..1, + ), + ( + Fn, + 3..5, + ), + ( + IdentifierOrGleamToken( + "concat(x:", + ), + 6..15, + ), + ( + IdentifierOrGleamToken( + "String,", + ), + 16..23, + ), + ( + IdentifierOrGleamToken( + "y:", + ), + 24..26, + ), + ( + IdentifierOrGleamToken( + "String)", + ), + 27..34, + ), + ( + IdentifierOrGleamToken( + "->", + ), + 35..37, + ), + ( + IdentifierOrGleamToken( + "String", + ), + 38..44, + ), + ( + CloseLine, + 44..44, + ), +] diff --git a/src/snapshots/matcha__scanner__test__scan_function_whole.snap b/src/snapshots/matcha__scanner__test__scan_function_whole.snap new file mode 100644 index 0000000..e2ec018 --- /dev/null +++ b/src/snapshots/matcha__scanner__test__scan_function_whole.snap @@ -0,0 +1,54 @@ +--- +source: src/scanner.rs +expression: "{> fn classes() -> String\nx y\n{> endfn\n" +--- +[ + ( + OpenLine, + 0..1, + ), + ( + Fn, + 3..5, + ), + ( + IdentifierOrGleamToken( + "classes()", + ), + 6..15, + ), + ( + IdentifierOrGleamToken( + "->", + ), + 16..18, + ), + ( + IdentifierOrGleamToken( + "String", + ), + 19..25, + ), + ( + CloseLine, + 25..25, + ), + ( + Text( + "x y\n", + ), + 26..30, + ), + ( + OpenLine, + 30..31, + ), + ( + EndFn, + 33..38, + ), + ( + CloseLine, + 38..38, + ), +] diff --git a/src/snapshots/matcha__scanner__test__scan_pub_function_whole.snap b/src/snapshots/matcha__scanner__test__scan_pub_function_whole.snap new file mode 100644 index 0000000..ad31030 --- /dev/null +++ b/src/snapshots/matcha__scanner__test__scan_pub_function_whole.snap @@ -0,0 +1,58 @@ +--- +source: src/scanner.rs +expression: "{> pub fn classes() -> String\nx y\n{> endfn\n" +--- +[ + ( + OpenLine, + 0..1, + ), + ( + Pub, + 3..6, + ), + ( + Fn, + 7..9, + ), + ( + IdentifierOrGleamToken( + "classes()", + ), + 10..19, + ), + ( + IdentifierOrGleamToken( + "->", + ), + 20..22, + ), + ( + IdentifierOrGleamToken( + "String", + ), + 23..29, + ), + ( + CloseLine, + 29..29, + ), + ( + Text( + "x y\n", + ), + 30..34, + ), + ( + OpenLine, + 34..35, + ), + ( + EndFn, + 37..42, + ), + ( + CloseLine, + 42..42, + ), +] diff --git a/test/template/builder.matcha b/test/templates/builder.matcha similarity index 100% rename from test/template/builder.matcha rename to test/templates/builder.matcha diff --git a/test/template/builder_expression.matcha b/test/templates/builder_expression.matcha similarity index 100% rename from test/template/builder_expression.matcha rename to test/templates/builder_expression.matcha diff --git a/test/template/dot_access.matcha b/test/templates/dot_access.matcha similarity index 100% rename from test/template/dot_access.matcha rename to test/templates/dot_access.matcha diff --git a/test/template/double_identifier_usage.matcha b/test/templates/double_identifier_usage.matcha similarity index 100% rename from test/template/double_identifier_usage.matcha rename to test/templates/double_identifier_usage.matcha diff --git a/test/template/for_as_loop.matcha b/test/templates/for_as_loop.matcha similarity index 100% rename from test/template/for_as_loop.matcha rename to test/templates/for_as_loop.matcha diff --git a/test/template/for_loop.matcha b/test/templates/for_loop.matcha similarity index 100% rename from test/template/for_loop.matcha rename to test/templates/for_loop.matcha diff --git a/test/template/for_loop_from_expression.matcha b/test/templates/for_loop_from_expression.matcha similarity index 100% rename from test/template/for_loop_from_expression.matcha rename to test/templates/for_loop_from_expression.matcha diff --git a/test/templates/function_html.matcha b/test/templates/function_html.matcha new file mode 100644 index 0000000..9a0b4a0 --- /dev/null +++ b/test/templates/function_html.matcha @@ -0,0 +1,8 @@ +{> fn item(name: String) +
  • {{ name }}
  • +{> endfn +
      + {[ item("Alice") ]} + {[ item("Bob") ]} + {[ item("Cary") ]} +
    diff --git a/test/templates/function_with_arg.matcha b/test/templates/function_with_arg.matcha new file mode 100644 index 0000000..a6f03c1 --- /dev/null +++ b/test/templates/function_with_arg.matcha @@ -0,0 +1,5 @@ +{> with second_name as String +{> fn full_name(second_name: String) +Lucy {{ second_name }} +{> endfn +Hello {[ full_name(second_name) ]} diff --git a/test/template/identifier.matcha b/test/templates/identifier.matcha similarity index 100% rename from test/template/identifier.matcha rename to test/templates/identifier.matcha diff --git a/test/template/if_comparison.matcha b/test/templates/if_comparison.matcha similarity index 100% rename from test/template/if_comparison.matcha rename to test/templates/if_comparison.matcha diff --git a/test/template/if_else_statement.matcha b/test/templates/if_else_statement.matcha similarity index 100% rename from test/template/if_else_statement.matcha rename to test/templates/if_else_statement.matcha diff --git a/test/template/if_statement.matcha b/test/templates/if_statement.matcha similarity index 100% rename from test/template/if_statement.matcha rename to test/templates/if_statement.matcha diff --git a/test/template/multiline.matcha b/test/templates/multiline.matcha similarity index 100% rename from test/template/multiline.matcha rename to test/templates/multiline.matcha diff --git a/test/template/nested_if_statement.matcha b/test/templates/nested_if_statement.matcha similarity index 100% rename from test/template/nested_if_statement.matcha rename to test/templates/nested_if_statement.matcha diff --git a/test/templates/pub_functions.matcha b/test/templates/pub_functions.matcha new file mode 100644 index 0000000..36d9b51 --- /dev/null +++ b/test/templates/pub_functions.matcha @@ -0,0 +1,3 @@ +{> pub fn hello_lucy() +Hello Lucy +{> endfn diff --git a/test/template/quote.matcha b/test/templates/quote.matcha similarity index 100% rename from test/template/quote.matcha rename to test/templates/quote.matcha diff --git a/test/template/two_identifiers.matcha b/test/templates/two_identifiers.matcha similarity index 100% rename from test/template/two_identifiers.matcha rename to test/templates/two_identifiers.matcha diff --git a/test/templates/use_pub_function.matcha b/test/templates/use_pub_function.matcha new file mode 100644 index 0000000..74019b3 --- /dev/null +++ b/test/templates/use_pub_function.matcha @@ -0,0 +1,2 @@ +{> import templates/pub_functions.{hello_lucy} +{[ hello_lucy() ]}, welcome to the test suite. diff --git a/test/template/value_expression.matcha b/test/templates/value_expression.matcha similarity index 100% rename from test/template/value_expression.matcha rename to test/templates/value_expression.matcha diff --git a/test/template/value_in_for_loop.matcha b/test/templates/value_in_for_loop.matcha similarity index 100% rename from test/template/value_in_for_loop.matcha rename to test/templates/value_in_for_loop.matcha diff --git a/test/template/value_in_if_else.matcha b/test/templates/value_in_if_else.matcha similarity index 100% rename from test/template/value_in_if_else.matcha rename to test/templates/value_in_if_else.matcha diff --git a/test/templates_test.gleam b/test/templates_test.gleam index 2c14165..89b9838 100644 --- a/test/templates_test.gleam +++ b/test/templates_test.gleam @@ -3,24 +3,27 @@ import gleam/string_builder import gleeunit import gleeunit/should -import template/identifier -import template/two_identifiers -import template/double_identifier_usage -import template/if_statement -import template/if_else_statement -import template/if_comparison -import template/nested_if_statement -import template/for_loop -import template/for_as_loop -import template/for_loop_from_expression -import template/dot_access -import template/multiline -import template/value_in_for_loop -import template/value_in_if_else -import template/value_expression -import template/quote -import template/builder -import template/builder_expression +import templates/identifier +import templates/two_identifiers +import templates/double_identifier_usage +import templates/if_statement +import templates/if_else_statement +import templates/if_comparison +import templates/nested_if_statement +import templates/for_loop +import templates/for_as_loop +import templates/for_loop_from_expression +import templates/dot_access +import templates/multiline +import templates/value_in_for_loop +import templates/value_in_if_else +import templates/value_expression +import templates/quote +import templates/builder +import templates/builder_expression +import templates/function_with_arg +import templates/function_html +import templates/use_pub_function import my_user.{User, NamedUser} @@ -170,3 +173,18 @@ pub fn quote_test() { quote.render(name: "Anna") |> should.equal("
    Anna
    \n") } + +pub fn function_test() { + function_with_arg.render(second_name: "Gleam") + |> should.equal("Hello Lucy Gleam\n") + + function_html.render() + |> should.equal("
      +
    • Alice
    • +
    • Bob
    • +
    • Cary
    • +
    \n") + + use_pub_function.render() + |> should.equal("Hello Lucy, welcome to the test suite.\n") +}