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
+
+
+ {[ item(name: "Alice") ]}
+ {[ item(name: "Bob") ]}
+ {[ item(name: "Cary") ]}
+
+```
+
+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("\n")
+
+ use_pub_function.render()
+ |> should.equal("Hello Lucy, welcome to the test suite.\n")
+}