diff --git a/Cargo.lock b/Cargo.lock index 72dd9f3..60269ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.15.0" @@ -420,6 +429,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "log" version = "0.4.29" @@ -439,7 +454,9 @@ dependencies = [ "annotate-snippets", "criterion", "displaydoc", + "document-features", "insta", + "nomo", "serde", "serde_json", "test_each_file", diff --git a/Cargo.toml b/Cargo.toml index 35f087d..6378b08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,14 @@ harness = false [profile.bench] debug = true +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" + [dependencies] annotate-snippets = "0.12.13" displaydoc = "0.2.5" +document-features = { version = "0.2.12", optional = true } serde_json = { version = "1.0.149", optional = true } thiserror = "2.0.18" winnow = { version = "0.7.14", features = ["unstable-recover"] } @@ -30,11 +35,19 @@ insta = { version = "1.46.3", features = ["glob", "serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" test_each_file = "0.3.7" +nomo = { path = ".", features = ["unstable-pub"] } [profile.dev.package] insta.opt-level = 3 similar.opt-level = 3 +[package.metadata.docs.rs] +features = ["document-features"] + [features] default = ["serde_json"] +## Add support for inserting [`serde_json::Value`]s into [`Context`] objects serde_json = ["dep:serde_json"] +## Get access to the internals of the crate ⚠️ This is a perma-unstable feature ⚠️ +unstable-pub = [] +document-features = ["dep:document-features"] diff --git a/benches/asting.rs b/benches/asting.rs index 3e8d755..5cf1b43 100644 --- a/benches/asting.rs +++ b/benches/asting.rs @@ -1,3 +1,5 @@ +#![allow(missing_docs)] + use criterion::BenchmarkId; use criterion::Criterion; use criterion::criterion_group; @@ -20,7 +22,7 @@ fn asting_benchmark(c: &mut Criterion) { parsing.bench_with_input(BenchmarkId::from_parameter(size), &input, |b, input| { b.iter(|| { let tokens = nomo::lexer::parse(input.clone()).unwrap(); - let _ast = nomo::parser::parse(tokens.tokens()).unwrap(); + let _ast = nomo::parser::parse(input.clone(), tokens.tokens()).unwrap(); }); }); } @@ -44,7 +46,7 @@ fn asting_nested(c: &mut Criterion) { parsing.bench_with_input(BenchmarkId::from_parameter(size), &input, |b, input| { b.iter(|| { let tokens = nomo::lexer::parse(input.clone()).unwrap(); - let _ast = nomo::parser::parse(tokens.tokens()).unwrap(); + let _ast = nomo::parser::parse(input.clone(), tokens.tokens()).unwrap(); }); }); } diff --git a/benches/parsing.rs b/benches/parsing.rs index 20548e6..455a964 100644 --- a/benches/parsing.rs +++ b/benches/parsing.rs @@ -1,3 +1,5 @@ +#![allow(missing_docs)] + use criterion::BenchmarkId; use criterion::Criterion; use criterion::criterion_group; diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index ee307ed..2cf07f7 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -112,7 +112,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "nomo" -version = "0.1.0" +version = "0.0.1" dependencies = [ "annotate-snippets", "displaydoc", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index e6af9c6..fe1acb7 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -12,6 +12,7 @@ libfuzzer-sys = "0.4" [dependencies.nomo] path = ".." +features = ["unstable-pub"] [[bin]] name = "fuzz_target_1" diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 4fc3644..8a18816 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -63,6 +63,7 @@ pub enum Instruction { slot: VariableSlot, }, PushScope { + #[allow(unused)] inherit_parent: bool, }, Abort, @@ -93,6 +94,7 @@ pub enum Instruction { value_slot: VariableSlot, }, LoadLiteralToSlot { + #[allow(unused)] source: TemplateToken, value: NomoValue, slot: VariableSlot, @@ -577,14 +579,15 @@ fn emit_expr_load( #[cfg(test)] mod tests { use crate::compiler::emit_machine; + use crate::input::NomoInput; #[test] fn check_simple_variable_interpolation() { - let input = "Hello {{= world }}"; + let input = NomoInput::from("Hello {{= world }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = crate::parser::parse(parsed.tokens()).unwrap(); + let ast = crate::parser::parse(input, parsed.tokens()).unwrap(); let emit = emit_machine(ast); @@ -617,11 +620,12 @@ mod tests { #[test] fn check_if_else_if() { - let input = "{{ if foo }} foo {{ else if bar }} bar {{ else }} foobar {{ end }}"; + let input = + NomoInput::from("{{ if foo }} foo {{ else if bar }} bar {{ else }} foobar {{ end }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = crate::parser::parse(parsed.tokens()).unwrap(); + let ast = crate::parser::parse(input, parsed.tokens()).unwrap(); let emit = emit_machine(ast); @@ -630,11 +634,11 @@ mod tests { #[test] fn check_function_call() { - let input = "{{ if foo(23) }} bar {{ else }} foobar {{ end }}"; + let input = NomoInput::from("{{ if foo(23) }} bar {{ else }} foobar {{ end }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = crate::parser::parse(parsed.tokens()).unwrap(); + let ast = crate::parser::parse(input, parsed.tokens()).unwrap(); let emit = emit_machine(ast); diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..11df09a --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,153 @@ +use std::sync::Arc; + +use annotate_snippets::AnnotationKind; +use annotate_snippets::Level; +use annotate_snippets::Patch; +use annotate_snippets::Renderer; +use annotate_snippets::Snippet; +use thiserror::Error; +use winnow::stream::Offset; + +use crate::input::NomoInput; +use crate::lexer::ParseError; +use crate::parser::AstError; + +/// An error occurred while producing an Ast +#[derive(Debug, Error)] +pub struct AstFailure { + errors: Vec, + input: NomoInput, +} + +impl std::fmt::Display for AstFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.render(f) + } +} + +impl AstFailure { + pub(crate) fn from_errors(errors: Vec, source: NomoInput) -> AstFailure { + AstFailure { + errors, + input: source, + } + } + + /// Create a CLI printable report + pub fn to_report(&self) -> String { + self.to_string() + } + + /// Render this failure to the given formatter + /// + /// Note, you can also use [`to_string`](ToString::to_string), as this type also implements + /// [`Display`](std::fmt::Display). + pub fn render(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let renderer = annotate_snippets::Renderer::styled() + .decor_style(annotate_snippets::renderer::DecorStyle::Unicode); + + for error in &self.errors { + let mut snippets = vec![ + annotate_snippets::Level::ERROR + .primary_title( + error + .message + .as_deref() + .unwrap_or("An error occurred while producing an Ast"), + ) + .element( + annotate_snippets::Snippet::source(self.input.as_str()).annotation( + annotate_snippets::AnnotationKind::Primary.span( + constrain_without_whitespace( + self.input.as_ref(), + error.span.clone().map(|s| s.range).unwrap_or_else(|| 0..0), + ), + ), + ), + ) + .elements( + error + .help + .as_ref() + .map(|help| annotate_snippets::Level::HELP.message(help)), + ), + ]; + if let Some((range, help)) = &error.replacement { + snippets.push( + annotate_snippets::Level::NOTE + .secondary_title("Try adding it") + .element( + Snippet::source(self.input.as_str()) + .patch(Patch::new(range.range.clone(), help.clone())), + ), + ); + } + + writeln!(f, "{}", renderer.render(&snippets))?; + writeln!(f)?; + } + + Ok(()) + } +} + +fn constrain_without_whitespace( + input: &str, + range: std::ops::Range, +) -> std::ops::Range { + let trimmed = input[range].trim(); + let start = trimmed.offset_from(&input); + let end = start + trimmed.len(); + + start..end +} + +/// An error occurred during lexing +#[derive(Debug, Error)] +pub struct ParseFailure { + input: Arc, + errors: Vec, +} + +impl std::fmt::Display for ParseFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_report()) + } +} + +impl ParseFailure { + pub(crate) fn from_errors(errors: Vec, input: NomoInput) -> ParseFailure { + ParseFailure { + input: Arc::from(input.to_string()), + errors, + } + } + + /// Produce a CLi printable report + pub fn to_report(&self) -> String { + let reports = self + .errors + .iter() + .map(|error| { + Level::ERROR + .primary_title( + error + .message + .as_deref() + .unwrap_or("An error occurred while parsing"), + ) + .element( + Snippet::source(self.input.as_ref()).annotation( + AnnotationKind::Primary + .span(error.span.clone().map(|s| s.range).unwrap_or_else(|| 0..0)), + ), + ) + .elements(error.help.as_ref().map(|help| Level::HELP.message(help))) + }) + .collect::>(); + + let renderer = + Renderer::styled().decor_style(annotate_snippets::renderer::DecorStyle::Unicode); + renderer.render(&reports) + } +} diff --git a/src/eval/mod.rs b/src/eval/mod.rs index 283432a..7a0957b 100644 --- a/src/eval/mod.rs +++ b/src/eval/mod.rs @@ -297,14 +297,15 @@ mod tests { use crate::eval::execute; use crate::functions::FunctionMap; use crate::functions::NomoFunctionError; + use crate::input::NomoInput; #[test] fn check_simple_variable_interpolation() { - let input = "Hello {{= world }}"; + let input = NomoInput::from("Hello {{= world }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = crate::parser::parse(parsed.tokens()).unwrap(); + let ast = crate::parser::parse(input, parsed.tokens()).unwrap(); let emit = crate::compiler::emit_machine(ast); @@ -321,11 +322,11 @@ mod tests { #[test] fn check_method_call() { - let input = "Hello {{= foo(world) }}"; + let input = NomoInput::from("Hello {{= foo(world) }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = crate::parser::parse(parsed.tokens()).unwrap(); + let ast = crate::parser::parse(input, parsed.tokens()).unwrap(); let emit = crate::compiler::emit_machine(ast); @@ -348,11 +349,11 @@ mod tests { #[test] fn check_conditional_access() { - let input = "Hello {{= unknown? }}"; + let input = NomoInput::from("Hello {{= unknown? }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = crate::parser::parse(parsed.tokens()).unwrap(); + let ast = crate::parser::parse(input, parsed.tokens()).unwrap(); let emit = crate::compiler::emit_machine(ast); diff --git a/src/functions.rs b/src/functions.rs index c15031c..1a2883d 100644 --- a/src/functions.rs +++ b/src/functions.rs @@ -7,42 +7,57 @@ use thiserror::Error; use crate::NomoValueError; use crate::value::NomoValue; +/// Possible errors while executing a function #[derive(Debug, Error, Display)] pub enum NomoFunctionError { /// Received {received} arguments, but this function only takes {expected} + #[expect(missing_docs)] WrongArgumentCount { received: usize, expected: usize }, /// The argument at this position is of the wrong type + #[expect(missing_docs)] InvalidArgumentType { index: usize }, /// A user-provided error #[error(transparent)] CustomError { + #[expect(missing_docs)] custom: Box, }, } +/// A function that can be used inside a template pub trait NomoFunction: 'static + Send + Sync { + /// Call the function with the given arguments fn call(&self, args: Vec) -> Result; } +#[cfg(feature = "unstable-pub")] #[derive(Default)] +#[expect(missing_docs)] pub struct FunctionMap { funcs: HashMap, } +#[cfg(not(feature = "unstable-pub"))] +#[derive(Default)] +pub(crate) struct FunctionMap { + funcs: HashMap, +} + impl FunctionMap { + #[expect(missing_docs)] pub fn register, T>(&mut self, name: impl Into, func: NF) { self.funcs .insert(name.into(), ErasedNomoFunction::erase(func)); } - pub fn get(&self, name: impl AsRef) -> Option<&ErasedNomoFunction> { + pub(crate) fn get(&self, name: impl AsRef) -> Option<&ErasedNomoFunction> { self.funcs.get(name.as_ref()) } } -pub struct ErasedNomoFunction { +pub(crate) struct ErasedNomoFunction { func: Box, call_fn: fn(&dyn Any, Vec) -> Result, } diff --git a/src/input.rs b/src/input.rs index ba404eb..70c01b2 100644 --- a/src/input.rs +++ b/src/input.rs @@ -8,6 +8,7 @@ use winnow::stream::Offset; use winnow::stream::Stream; use winnow::stream::StreamIsPartial; +/// The input for templates in [nomo](crate) #[derive(Clone, PartialEq, Eq)] pub struct NomoInput { backing: Arc, @@ -15,19 +16,26 @@ pub struct NomoInput { } impl NomoInput { + /// Manually create an input + /// + /// While it is not unsafe to pass in mismatched informations, the outcome will most likely not + /// work and possibly even panic later pub fn from_parts(backing: Arc, range: Range) -> NomoInput { NomoInput { backing, range } } + /// Turn the input into its parts pub fn into_parts(self) -> (Arc, Range) { (self.backing, self.range) } + /// Get the range this input covers pub fn get_range(&self) -> Range { self.range.clone() } } +#[doc(hidden)] #[derive(Debug, Clone)] pub struct NomoInputCheckpoint { range: Range, @@ -78,6 +86,7 @@ impl Deref for NomoInput { } impl NomoInput { + /// Get the input as a [`str`] pub fn as_str(&self) -> &str { self.deref() } @@ -101,6 +110,8 @@ impl Offset for NomoInput { } } + +#[doc(hidden)] pub struct NomoInputIter { idx: usize, input: NomoInput, diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index 277f3e3..94daf26 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -1,10 +1,3 @@ -use std::sync::Arc; - -use annotate_snippets::AnnotationKind; -use annotate_snippets::Level; -use annotate_snippets::Renderer; -use annotate_snippets::Snippet; -use thiserror::Error; use winnow::LocatingSlice; use winnow::Parser; use winnow::RecoverableParser; @@ -39,60 +32,13 @@ use winnow::token::take_until; use winnow::token::take_while; use crate::SourceSpan; +use crate::errors::ParseFailure; use crate::input::NomoInput; use crate::resume_after_cut; type Input<'input> = Recoverable, ParseError>; type PResult<'input, T> = Result; -#[derive(Debug, Error)] -pub struct ParseFailure { - input: Arc, - errors: Vec, -} - -impl std::fmt::Display for ParseFailure { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.to_report()) - } -} - -impl ParseFailure { - fn from_errors(errors: Vec, input: NomoInput) -> ParseFailure { - ParseFailure { - input: Arc::from(input.to_string()), - errors, - } - } - - pub fn to_report(&self) -> String { - let reports = self - .errors - .iter() - .map(|error| { - Level::ERROR - .primary_title( - error - .message - .as_deref() - .unwrap_or("An error occurred while parsing"), - ) - .element( - Snippet::source(self.input.as_ref()).annotation( - AnnotationKind::Primary - .span(error.span.clone().map(|s| s.range).unwrap_or_else(|| 0..0)), - ), - ) - .elements(error.help.as_ref().map(|help| Level::HELP.message(help))) - }) - .collect::>(); - - let renderer = - Renderer::styled().decor_style(annotate_snippets::renderer::DecorStyle::Unicode); - renderer.render(&reports) - } -} - #[derive(Debug, Clone)] pub struct ParseError { pub(crate) message: Option, diff --git a/src/lib.rs b/src/lib.rs index 7874d4b..abddcf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,102 @@ +//! # Nomo, the templating library +//! +//! To get started, add the crate to your project: +//! +//! ```bash +//! cargo add nomo +//! ``` +//! +//! Then, load some templates: +//! +//! ```rust +//! # fn main() -> Result<(), Box> { +//! let mut templates = nomo::Nomo::new(); +//! templates.add_template("index.html", "

Hello {{= name }}!

"); +//! +//! let mut context = nomo::Context::new(); +//! context.insert("name", "World"); +//! let result = templates.render("index.html", &context)?; +//! +//! assert_eq!(result, "

Hello World!

"); +//! # Ok(()) } +//! ``` +//! +//! The crate has the following feature flags: +#![cfg_attr( + feature = "document-features", + cfg_attr(doc, doc = ::document_features::document_features!()) +)] +//! +//! ## Syntax +//! +//! Nomo tries to keep a consistent syntax across its features. +//! The main concepts are: +//! +//! [`nomo`](crate) uses `{{ }}` as its identifiers. Each of them forms a 'block'. +//! +//! There are two kinds of blocks: +//! +//! - `{{= }}` Interpolations +//! - `{{ }}` everything else +//! +//! ### Expressions +//! +//! An expression in [`nomo`](crate) is anything that produces a value. Notably, control structures +//! _do not_ create values. This is different to rust. +//! +//! So for example this does not work: +//! +//! ```nomo +//! {{= if is_active "Active" else "Inactive" end }} +//! ``` +//! +//! If you wish to conditionally output something, you would write: +//! +//! ```nomo +//! {{ if is_active }} +//! Active +//! {{ else }} +//! Inactive +//! {{ end }} +//! ``` +//! +//! In expressions you can write: +//! - Mathematical expressions (`+-*/`) +//! - Logical/Binary expressions (`&&`, `||`, `>`, ...) +//! - Literals (`12`, `292.21`, `"Hello"`) +//! +//! ### Interpolations +//! +//! Interpolations is how one prints out data. A [`NomoValue`] can be printed if it is a: +//! +//! - String +//! - Integer/SignedInteger +//! - Bool +//! - Float +//! +//! All other values will result in an error. +//! +//! ### Control Structures +//! +//! [`Nomo`](crate) supports several control structures: +//! +//! **Conditions `if/else`**: +//! +//! ```nomo +//! {{ if }} +//! {{ else if }} +//! {{ else }} +//! {{ end }} +//! ``` +//! +//! **Loops `for .. in`** +//! +//! ```nomo +//! {{ for in }} +//! {{ else }} +//! {{ end }} +//! ``` + use std::collections::HashMap; use displaydoc::Display; @@ -5,34 +104,71 @@ use thiserror::Error; use crate::compiler::VMInstructions; use crate::functions::FunctionMap; +use crate::functions::NomoFunction; use crate::input::NomoInput; use crate::value::NomoValue; use crate::value::NomoValueError; -pub mod compiler; -pub mod eval; +macro_rules! unstable_pub { + ($(#[$m:meta])* mod $name:ident) => { + $(#[$m])* + #[cfg(feature = "unstable-pub")] + #[allow(missing_docs)] + pub mod $name; + #[cfg(not(feature = "unstable-pub"))] + mod $name; + }; +} + +unstable_pub!( + /// The compiler internals + mod compiler +); +unstable_pub!( + /// Evaluation internals + mod eval +); +unstable_pub!( + /// Lexer internals + mod lexer +); +unstable_pub!( + /// Parser internals + mod parser +); + +mod winnow_ext; + +/// Errors in this library +pub mod errors; + +/// Nomo Functions pub mod functions; +/// Input for nomo pub mod input; -pub mod lexer; -pub mod parser; +/// Values used in Nomo pub mod value; +/// Errors related to parsing and evaluating templates #[derive(Debug, Error, Display)] pub enum NomoError { /// Could not parse the given template ParseError { #[from] - source: lexer::ParseFailure, + #[expect(missing_docs)] + source: errors::ParseFailure, }, /// Invalid Template AstError { #[from] - source: parser::AstFailure, + #[expect(missing_docs)] + source: errors::AstFailure, }, /// An error occurred while evaluating EvaluationError { #[from] + #[expect(missing_docs)] source: eval::EvaluationError, }, @@ -40,6 +176,7 @@ pub enum NomoError { UnknownTemplate(String), } +/// The main struct and entry point for the [`nomo`](crate) pub struct Nomo { templates: HashMap, function_map: FunctionMap, @@ -52,6 +189,7 @@ impl Default for Nomo { } impl Nomo { + /// Create a new Nomo Instance pub fn new() -> Nomo { Nomo { templates: HashMap::new(), @@ -59,6 +197,7 @@ impl Nomo { } } + /// Add a new template pub fn add_template( &mut self, name: impl Into, @@ -66,7 +205,7 @@ impl Nomo { ) -> Result<(), NomoError> { let source = value.into(); let parse = lexer::parse(source.clone())?; - let ast = parser::parse(parse.tokens())?; + let ast = parser::parse(source.clone(), parse.tokens())?; let instructions = compiler::emit_machine(ast); @@ -76,6 +215,17 @@ impl Nomo { Ok(()) } + /// Register a function to make it available when rendering + pub fn register_function(&mut self, name: impl Into, f: impl NomoFunction) { + self.function_map.register(name, f); + } + + /// List of currently available templates + pub fn templates(&self) -> Vec { + self.templates.keys().cloned().collect() + } + + /// Render a specific template pub fn render(&self, name: &str, ctx: &Context) -> Result { let template = self .templates @@ -92,6 +242,7 @@ struct Template { instructions: VMInstructions, } +/// The context for a given render call pub struct Context { values: HashMap, } @@ -103,12 +254,16 @@ impl Default for Context { } impl Context { + /// Create new Context pub fn new() -> Context { Context { values: HashMap::new(), } } + /// Add a value into the map + /// + /// If you enable the `serde_json` feature, you can insert [`serde_json::Value`]s pub fn try_insert( &mut self, key: impl Into, @@ -119,17 +274,19 @@ impl Context { Ok(()) } + /// Add a value into the map, panic if you can't pub fn insert(&mut self, key: impl Into, value: impl Into) { self.values.insert(key.into(), value.into()); } + /// Access the values inside this context pub fn values(&self) -> &HashMap { &self.values } } #[derive(Debug, Clone)] -pub struct SourceSpan { +struct SourceSpan { pub range: std::ops::Range, } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 43481a2..decda96 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,4 +1,3 @@ -use thiserror::Error; use winnow::Parser; use winnow::RecoverableParser; use winnow::combinator::Infix::Left; @@ -21,6 +20,7 @@ use winnow::error::AddContext; use winnow::error::FromRecoverableError; use winnow::error::ModalError; use winnow::error::ParserError; +use winnow::stream::Location; use winnow::stream::Offset; use winnow::stream::Recoverable; use winnow::stream::Stream; @@ -28,6 +28,8 @@ use winnow::stream::TokenSlice; use winnow::token::any; use crate::SourceSpan; +use crate::errors::AstFailure; +use crate::input::NomoInput; use crate::lexer::TemplateToken; use crate::lexer::TokenKind; use crate::lexer::TokenOperator; @@ -50,6 +52,7 @@ pub struct AstError { pub(crate) message: Option, pub(crate) help: Option, pub(crate) span: Option, + pub(crate) replacement: Option<(SourceSpan, String)>, is_fatal: bool, } @@ -60,6 +63,7 @@ impl AstError { message: None, help: None, span: None, + replacement: None, is_fatal: false, } @@ -74,6 +78,11 @@ impl AstError { self.help = Some(help.to_string()); self } + + fn replacement(mut self, span: SourceSpan, replacement: &str) -> Self { + self.replacement = Some((span, replacement.to_string())); + self + } } impl ModalError for AstError { @@ -104,13 +113,14 @@ impl FromRecoverableError, AstError> for AstError { .filter(|t| t.kind() != TokenKind::Whitespace); let last = tokens.next(); let first = tokens.last(); + match (last, first) { (None, None) => None, (None, Some(single)) | (Some(single), None) => Some(SourceSpan { range: single.source().get_range(), }), (Some(last), Some(first)) => { - let start = first.source().get_range().start; + let start = first.source().get_range().end; let end = last.source().get_range().end; Some(SourceSpan { range: start..end }) @@ -131,6 +141,7 @@ impl AddContext, AstError> for AstError { ) -> Self { self.message = context.message.or(self.message); self.help = context.help.or(self.help); + self.replacement = context.replacement.or(self.replacement); self } } @@ -151,55 +162,6 @@ impl ParserError> for AstError { } } -#[derive(Debug, Error)] -pub struct AstFailure { - errors: Vec, -} - -impl std::fmt::Display for AstFailure { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("TODO") - } -} - -impl AstFailure { - fn from_errors(errors: Vec) -> AstFailure { - AstFailure { errors } - } - - pub fn to_report(&self, source: &str) -> String { - let reports = self - .errors - .iter() - .map(|error| { - annotate_snippets::Level::ERROR - .primary_title( - error - .message - .as_deref() - .unwrap_or("An error occurred while producing an Ast"), - ) - .element( - annotate_snippets::Snippet::source(source).annotation( - annotate_snippets::AnnotationKind::Primary - .span(error.span.clone().map(|s| s.range).unwrap_or_else(|| 0..0)), - ), - ) - .elements( - error - .help - .as_ref() - .map(|help| annotate_snippets::Level::HELP.message(help)), - ) - }) - .collect::>(); - - let renderer = annotate_snippets::Renderer::styled() - .decor_style(annotate_snippets::renderer::DecorStyle::Unicode); - renderer.render(&reports) - } -} - type Input<'input> = Recoverable, AstError>; impl<'input> Parser, TemplateToken, AstError> for TokenKind { @@ -210,7 +172,7 @@ impl<'input> Parser, TemplateToken, AstError> for TokenKind { } } -pub fn parse(input: &[TemplateToken]) -> Result, AstFailure> { +pub fn parse(source: NomoInput, input: &[TemplateToken]) -> Result, AstFailure> { let (_remaining, val, errors) = parse_asts.recoverable_parse(TokenSlice::new(input)); if errors.is_empty() @@ -218,7 +180,7 @@ pub fn parse(input: &[TemplateToken]) -> Result, AstFailure> { { Ok(TemplateAst { root: val }) } else { - Err(AstFailure::from_errors(errors)) + Err(AstFailure::from_errors(errors, source)) } } @@ -261,6 +223,7 @@ pub enum TemplateAstExpr<'input> { ElseConditional { expression: Option>>, }, + #[allow(unused)] Invalid(&'input [TemplateToken]), MathOperation { op: TokenOperator, @@ -356,26 +319,42 @@ fn parse_action<'input>(input: &mut Input<'input>) -> Result( + input: &mut Input<'input>, +) -> Result, AstError> { + let expression = peek(parse_expression).parse_next(input); + + let is_expr = match expression { + Ok(_) => true, + Err(err) if err.is_backtrack() => true, + _ => false, + }; + + if is_expr { + return Err(AstError::ctx() + .msg("Standlone action block") + .help("If you want to output this expression, add a '=' to the block") + .cut()); + } + + let keywords = peek(parse_keyword).parse_next(input); + + let is_keyword = keywords.is_ok(); + + if is_keyword { + return Err(AstError::ctx().msg("Unexpected keyword").cut()); + } + + Err(AstError::ctx().cut()) +} fn parse_for_chain<'input>(input: &mut Input<'input>) -> Result, AstError> { trace("for_loop", |input: &mut Input<'input>| { let for_block = parse_for_loop.map(Box::new).parse_next(input)?; @@ -390,7 +369,7 @@ fn parse_for_chain<'input>(input: &mut Input<'input>) -> Result(input: &mut Input<'input>) -> Result(input: &mut Input<'input>) -> Result, AstError> { trace( "for_loop_inner", - parse_block( - ( - ws, - TokenKind::For, - ws, - TokenKind::Ident, - ws, - TokenKind::In, - ws, - parse_expression.map(Box::new), + parse_block(|input: &mut Input<'input>| { + let _ignored = surrounded(ws, TokenKind::For).parse_next(input)?; + + let value_ident = + cut_err(TokenKind::Ident.context(AstError::ctx().msg("Missing ident here"))) + .parse_next(input)?; + + let _ignored = cut_err( + preceded(ws, TokenKind::In).context( + AstError::ctx() + .msg("Missing `in` in `for .. in ` loop") + .replacement( + SourceSpan { + range: input.current_token_start()..(input.current_token_start()), + }, + " in", + ), + ), ) - .map(|(_, _for, _, value_ident, _, _in, _, value_expression)| { - TemplateAstExpr::For { - value_ident, - value_expression, - } - }), - ), + .parse_next(input)?; + + let value_expression = parse_expression.map(Box::new).parse_next(input)?; + + Ok(TemplateAstExpr::For { + value_ident, + value_expression, + }) + }), ) .parse_next(input) } @@ -649,11 +638,37 @@ where fn parse_operand<'input>(input: &mut Input<'input>) -> Result, AstError> { trace( "operand", - alt((parse_function, parse_variable_access, parse_literal)), + alt(( + parse_keywords_fail, + parse_function, + parse_variable_access, + parse_literal, + )), ) .parse_next(input) } +fn parse_keyword<'input>(input: &mut Input<'input>) -> Result { + alt(( + TokenKind::ConditionalIf, + TokenKind::ConditionalElse, + TokenKind::For, + TokenKind::End, + TokenKind::In, + )) + .parse_next(input) +} + +fn parse_keywords_fail<'input>( + input: &mut Input<'input>, +) -> Result, AstError> { + let _value = parse_keyword.parse_next(input)?; + + Err(AstError::ctx() + .msg("Found literal, expected expression") + .cut()) +} + fn parse_literal<'input>(input: &mut Input<'input>) -> Result, AstError> { trace( "literal", @@ -757,6 +772,7 @@ mod tests { use winnow::combinator::fail; use winnow::stream::TokenSlice; + use crate::input::NomoInput; use crate::lexer::TokenKind; use crate::parser::AstError; use crate::parser::AstFailure; @@ -766,25 +782,22 @@ mod tests { use crate::parser::parse_block; use crate::parser::parse_end; - fn panic_pretty<'a>( - input: &'_ str, - tokens: Result, AstFailure>, - ) -> TemplateAst<'a> { + fn panic_pretty<'a>(tokens: Result, AstFailure>) -> TemplateAst<'a> { match tokens { Ok(ast) => ast, Err(failure) => { - panic!("{}", failure.to_report(input)); + panic!("{}", failure); } } } #[test] fn check_only_content() { - let input = "Hello World"; + let input = NomoInput::from("Hello World"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = parse(parsed.tokens()).unwrap(); + let ast = parse(input, parsed.tokens()).unwrap(); insta::assert_debug_snapshot!(ast, @r#" TemplateAst { @@ -799,11 +812,11 @@ mod tests { #[test] fn check_simple_variable_interpolation() { - let input = "Hello {{= world }}"; + let input = NomoInput::from("Hello {{= world }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = parse(parsed.tokens()).unwrap(); + let ast = parse(input, parsed.tokens()).unwrap(); insta::assert_debug_snapshot!(ast, @r#" TemplateAst { @@ -827,11 +840,11 @@ mod tests { #[test] fn check_simple_if() { - let input = "{{ if foo }} Hiii {{ end }}"; + let input = NomoInput::from("{{ if foo }} Hiii {{ end }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast, @r#" TemplateAst { @@ -872,35 +885,39 @@ mod tests { #[test] fn check_invalid_action() { - let input = r#"{{ value }} + let input = NomoInput::from( + r#"{{ value }} {{ value }} {{ value }} {{ value }} - {{ value }}"#; + {{ value }}"#, + ); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = parse(parsed.tokens()).unwrap_err(); + let ast = parse(input, parsed.tokens()).unwrap_err(); - insta::assert_snapshot!(ast.to_report(input)); + insta::assert_snapshot!(ast); } #[test] fn check_nested_simple_if() { - let input = r#"{{ if foo }} + let input = NomoInput::from( + r#"{{ if foo }} {{ if bar }} Hiii {{ end }} {{ end }} {{= value }} - "#; + "#, + ); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); insta::assert_debug_snapshot!("simple_if_tokens", parsed); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!("simple_if_ast", ast); } @@ -909,9 +926,9 @@ mod tests { fn check_parsing_block() { use winnow::RecoverableParser; - let input = "{{ foo }}"; + let input = NomoInput::from("{{ foo }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input).unwrap(); let result = alt(( parse_end, @@ -943,9 +960,10 @@ mod tests { help: None, span: Some( SourceSpan { - range: 0..6, + range: 2..6, }, ), + replacement: None, is_fatal: false, }, ], @@ -955,11 +973,11 @@ mod tests { #[test] fn check_empty_if_output() { - let input = "{{ if foo }}{{ end }}"; + let input = NomoInput::from("{{ if foo }}{{ end }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast, @r#" TemplateAst { @@ -992,99 +1010,101 @@ mod tests { #[test] fn check_if_else() { - let input = "{{ if foo }} foo {{ else }} bar {{ end }}"; + let input = NomoInput::from("{{ if foo }} foo {{ else }} bar {{ end }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } #[test] fn check_if_else_if() { - let input = "{{ if foo }} foo {{ else if bar }} bar {{ end }}"; + let input = NomoInput::from("{{ if foo }} foo {{ else if bar }} bar {{ end }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } #[test] fn check_trim_whitespace() { - let input = "{{ if foo -}} foo {{- else if bar -}} bar {{- end }}"; + let input = NomoInput::from("{{ if foo -}} foo {{- else if bar -}} bar {{- end }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } #[test] fn check_for_loop() { - let input = "{{ for value in array }} Hi: {{= value }} {{ else }} No Content :C {{ end }}"; + let input = NomoInput::from( + "{{ for value in array }} Hi: {{= value }} {{ else }} No Content :C {{ end }}", + ); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } #[test] fn check_math_expression() { - let input = "{{= 5 * 3 + 2 / 3 }}"; + let input = NomoInput::from("{{= 5 * 3 + 2 / 3 }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } #[test] fn check_logical_expression() { - let input = "{{= true && false || 3 >= 2 && 5 == 2 }}"; + let input = NomoInput::from("{{= true && false || 3 >= 2 && 5 == 2 }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } #[test] fn check_function_call() { - let input = "{{= foo(2 * 3, bar(2 + baz)) }}"; + let input = NomoInput::from("{{= foo(2 * 3, bar(2 + baz)) }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } #[test] fn check_conditional_access() { - let input = "{{= foo? }}"; + let input = NomoInput::from("{{= foo? }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } #[test] fn check_access_operator() { - let input = "{{= foo?.bar }}"; + let input = NomoInput::from("{{= foo?.bar }}"); - let parsed = crate::lexer::parse(input.into()).unwrap(); + let parsed = crate::lexer::parse(input.clone()).unwrap(); - let ast = panic_pretty(input, parse(parsed.tokens())); + let ast = panic_pretty(parse(input, parsed.tokens())); insta::assert_debug_snapshot!(ast); } diff --git a/src/parser/snapshots/nomo__parser__tests__check_invalid_action.snap b/src/parser/snapshots/nomo__parser__tests__check_invalid_action.snap index 0587130..d4b9694 100644 --- a/src/parser/snapshots/nomo__parser__tests__check_invalid_action.snap +++ b/src/parser/snapshots/nomo__parser__tests__check_invalid_action.snap @@ -1,6 +1,6 @@ --- source: src/parser/mod.rs -expression: ast.to_report(input) +expression: ast --- error: Standlone action block  ╭▸  @@ -8,23 +8,31 @@ expression: ast.to_report(input) │ ━━━━━ │ ╰ help: If you want to output this expression, add a '=' to the block + error: Standlone action block  ╭▸  2 │ {{ value }} │ ━━━━━ + │ ╰ help: If you want to output this expression, add a '=' to the block + error: Standlone action block  ╭▸  3 │ {{ value }} │ ━━━━━ + │ ╰ help: If you want to output this expression, add a '=' to the block + error: Standlone action block  ╭▸  4 │ {{ value }} │ ━━━━━ + │ ╰ help: If you want to output this expression, add a '=' to the block + error: Standlone action block  ╭▸  5 │ {{ value }} │ ━━━━━ + │ ╰ help: If you want to output this expression, add a '=' to the block diff --git a/src/value.rs b/src/value.rs index ab317bb..b652dfd 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1,11 +1,12 @@ use std::borrow::Cow; use std::collections::BTreeMap; -#[cfg(feature = "serde_json")] use displaydoc::Display; use thiserror::Error; +/// Values to be used inside templates #[derive(Clone)] +#[expect(missing_docs)] pub enum NomoValue { String { value: Cow<'static, str>, @@ -35,6 +36,7 @@ pub enum NomoValue { } impl NomoValue { + /// Return a str if there is one inside pub fn as_str(&self) -> Option<&str> { if let Self::String { value } = self { Some(value) @@ -43,6 +45,7 @@ impl NomoValue { } } + /// Return an arry if there is one inside pub fn as_array(&self) -> Option<&[NomoValue]> { if let Self::Array { value } = self { Some(value) @@ -51,6 +54,7 @@ impl NomoValue { } } + /// Return a bool if there is one inside pub fn as_bool(&self) -> Option { if let Self::Bool { value } = self { Some(*value) @@ -61,6 +65,7 @@ impl NomoValue { } } + /// Return an object if there is one inside pub fn as_object(&self) -> Option<&BTreeMap> { if let Self::Object { value } = self { Some(value) @@ -69,6 +74,7 @@ impl NomoValue { } } + /// Return an integer if there is one inside pub fn as_integer(&self) -> Option { if let Self::Integer { value } = self { Some(*value) @@ -77,6 +83,7 @@ impl NomoValue { } } + /// Return a float if there is one inside pub fn as_float(&self) -> Option { if let Self::Float { value } = self { Some(*value) @@ -85,6 +92,7 @@ impl NomoValue { } } + /// Return the iterator if there is one inside pub fn as_iterator(&self) -> Option<&dyn CloneIterator> { if let Self::Iterator { value } = self { Some(value) @@ -93,6 +101,7 @@ impl NomoValue { } } + /// Return the iterator mutably if there is one inside pub fn as_iterator_mut(&mut self) -> Option<&mut dyn CloneIterator> { if let Self::Iterator { value } = self { Some(value) @@ -237,19 +246,21 @@ impl NomoValue { pub(crate) fn try_to_string(&self) -> Option { match self { NomoValue::String { value } => Some(value.to_string()), - NomoValue::Array { .. } => None, NomoValue::Bool { value } => Some(value.to_string()), - NomoValue::Object { .. } => None, NomoValue::Integer { value } => Some(value.to_string()), NomoValue::SignedInteger { value } => Some(value.to_string()), NomoValue::Float { value } => Some(value.to_string()), + NomoValue::Array { .. } => None, + NomoValue::Object { .. } => None, NomoValue::Iterator { .. } => None, NomoValue::Undefined => None, } } } +/// Marker trait for iterators that can be cloned pub trait CloneIterator: Iterator { + /// Create a new instance of the iterator inside a [Box] fn clone_box(&self) -> Box>; } diff --git a/src/winnow_ext.rs b/src/winnow_ext.rs new file mode 100644 index 0000000..4d7450a --- /dev/null +++ b/src/winnow_ext.rs @@ -0,0 +1,51 @@ +use std::marker::PhantomData; + +use winnow::Parser; +use winnow::error::AddContext; +use winnow::error::ParserError; +use winnow::stream::Stream; + +pub trait ParserExt: Parser { + fn with_context(self, context: F) -> WithContext + where + Self: Sized, + I: Stream, + E: AddContext + ParserError, + F: Fn(&::Slice, &E) -> C, + { + WithContext { + parser: self, + context, + _pd: PhantomData, + } + } +} + +pub struct WithContext { + parser: P, + context: F, + _pd: PhantomData<(I, O, E, C)>, +} + +impl Parser for WithContext +where + P: Parser, + I: Stream, + E: AddContext + ParserError, + F: Fn(&::Slice, &E) -> C, +{ + fn parse_next(&mut self, input: &mut I) -> winnow::Result { + let start = input.checkpoint(); + + let res = self.parser.parse_next(input); + + res.map_err(|err| { + let offset = input.offset_from(&start); + input.reset(&start); + let taken = input.next_slice(offset); + + let context = (self.context)(&taken, &err); + err.add_context(input, &start, context) + }) + } +} diff --git a/tests/checks.rs b/tests/checks.rs index 200ac20..29278b1 100644 --- a/tests/checks.rs +++ b/tests/checks.rs @@ -1,3 +1,5 @@ +#![allow(missing_docs)] + #[test] fn check_files() { let files = std::fs::read_dir("tests/checks/").unwrap(); @@ -5,11 +7,13 @@ fn check_files() { for file in files { let input = std::fs::read_to_string(file.unwrap().path()).unwrap(); - let Ok(parsed) = nomo::lexer::parse(input.into()) else { + let input = nomo::input::NomoInput::from(input); + + let Ok(parsed) = nomo::lexer::parse(input.clone()) else { continue; }; - let Ok(ast) = nomo::parser::parse(parsed.tokens()) else { + let Ok(ast) = nomo::parser::parse(input, parsed.tokens()) else { continue; }; diff --git a/tests/errors/invalid_controls.1-parsed.snap b/tests/errors/invalid_controls.1-parsed.snap new file mode 100644 index 0000000..1ae070d --- /dev/null +++ b/tests/errors/invalid_controls.1-parsed.snap @@ -0,0 +1,119 @@ +--- +source: tests/file_tests.rs +expression: parsed +info: + input: "{{ if }} {{ end }}\n{{ if if }} {{ end }}\n{{ if if }} {{ for foo in bar }} {{ end }} {{ end }}\n{{ for in bar }} {{ end }}\n{{ for blah bar }} {{ end }}\n{{ for blah}} {{ end }}\n{{ else }}" + context: {} +--- +ParsedTemplate { + tokens: [ + [LeftDelim]"{{" (0..2), + [Whitespace]" " (2..3), + [ConditionalIf]"if" (3..5), + [Whitespace]" " (5..6), + [RightDelim]"}}" (6..8), + [Whitespace]" " (8..9), + [LeftDelim]"{{" (9..11), + [Whitespace]" " (11..12), + [End]"end" (12..15), + [Whitespace]" " (15..16), + [RightDelim]"}}" (16..18), + [Whitespace]"\n" (18..19), + [LeftDelim]"{{" (19..21), + [Whitespace]" " (21..22), + [ConditionalIf]"if" (22..24), + [Whitespace]" " (24..25), + [ConditionalIf]"if" (25..27), + [Whitespace]" " (27..28), + [RightDelim]"}}" (28..30), + [Whitespace]" " (30..31), + [LeftDelim]"{{" (31..33), + [Whitespace]" " (33..34), + [End]"end" (34..37), + [Whitespace]" " (37..38), + [RightDelim]"}}" (38..40), + [Whitespace]"\n" (40..41), + [LeftDelim]"{{" (41..43), + [Whitespace]" " (43..44), + [ConditionalIf]"if" (44..46), + [Whitespace]" " (46..47), + [ConditionalIf]"if" (47..49), + [Whitespace]" " (49..50), + [RightDelim]"}}" (50..52), + [Whitespace]" " (52..53), + [LeftDelim]"{{" (53..55), + [Whitespace]" " (55..56), + [For]"for" (56..59), + [Whitespace]" " (59..60), + [Ident]"foo" (60..63), + [Whitespace]" " (63..64), + [In]"in" (64..66), + [Whitespace]" " (66..67), + [Ident]"bar" (67..70), + [Whitespace]" " (70..71), + [RightDelim]"}}" (71..73), + [Whitespace]" " (73..74), + [LeftDelim]"{{" (74..76), + [Whitespace]" " (76..77), + [End]"end" (77..80), + [Whitespace]" " (80..81), + [RightDelim]"}}" (81..83), + [Whitespace]" " (83..84), + [LeftDelim]"{{" (84..86), + [Whitespace]" " (86..87), + [End]"end" (87..90), + [Whitespace]" " (90..91), + [RightDelim]"}}" (91..93), + [Whitespace]"\n" (93..94), + [LeftDelim]"{{" (94..96), + [Whitespace]" " (96..97), + [For]"for" (97..100), + [Whitespace]" " (100..101), + [In]"in" (101..103), + [Whitespace]" " (103..104), + [Ident]"bar" (104..107), + [Whitespace]" " (107..108), + [RightDelim]"}}" (108..110), + [Whitespace]" " (110..111), + [LeftDelim]"{{" (111..113), + [Whitespace]" " (113..114), + [End]"end" (114..117), + [Whitespace]" " (117..118), + [RightDelim]"}}" (118..120), + [Whitespace]"\n" (120..121), + [LeftDelim]"{{" (121..123), + [Whitespace]" " (123..124), + [For]"for" (124..127), + [Whitespace]" " (127..128), + [Ident]"blah" (128..132), + [Whitespace]" " (132..133), + [Ident]"bar" (133..136), + [Whitespace]" " (136..137), + [RightDelim]"}}" (137..139), + [Whitespace]" " (139..140), + [LeftDelim]"{{" (140..142), + [Whitespace]" " (142..143), + [End]"end" (143..146), + [Whitespace]" " (146..147), + [RightDelim]"}}" (147..149), + [Whitespace]"\n" (149..150), + [LeftDelim]"{{" (150..152), + [Whitespace]" " (152..153), + [For]"for" (153..156), + [Whitespace]" " (156..157), + [Ident]"blah" (157..161), + [RightDelim]"}}" (161..163), + [Whitespace]" " (163..164), + [LeftDelim]"{{" (164..166), + [Whitespace]" " (166..167), + [End]"end" (167..170), + [Whitespace]" " (170..171), + [RightDelim]"}}" (171..173), + [Whitespace]"\n" (173..174), + [LeftDelim]"{{" (174..176), + [Whitespace]" " (176..177), + [ConditionalElse]"else" (177..181), + [Whitespace]" " (181..182), + [RightDelim]"}}" (182..184), + ], +} diff --git a/tests/errors/invalid_controls.2-ast.snap b/tests/errors/invalid_controls.2-ast.snap new file mode 100644 index 0000000..e3d5494 --- /dev/null +++ b/tests/errors/invalid_controls.2-ast.snap @@ -0,0 +1,51 @@ +--- +source: tests/file_tests.rs +expression: ast +info: + input: "{{ if }} {{ end }}\n{{ if if }} {{ end }}\n{{ if if }} {{ for foo in bar }} {{ end }} {{ end }}\n{{ for in bar }} {{ end }}\n{{ for blah bar }} {{ end }}\n{{ for blah}} {{ end }}\n{{ else }}" + context: {} +--- +error: Expected an expression after 'if' +  ╭▸  +1 │ {{ if }} {{ end }} + ╰╴ ━━ + +error: Expected an expression after 'if' +  ╭▸  +2 │ {{ if if }} {{ end }} + ╰╴ ━━ + +error: Expected an expression after 'if' +  ╭▸  +3 │ {{ if if }} {{ for foo in bar }} {{ end }} {{ end }} + ╰╴ ━━ + +error: Missing ident here +  ╭▸  +4 │ {{ for in bar }} {{ end }} + ╰╴ ━━━━━━ + +error: Missing `in` in `for .. in ` loop +  ╭▸  +5 │ {{ for blah bar }} {{ end }} + │ ━━━━━━━━ + ╰╴ +note: Try adding it + ╭╴ +5 │ {{ for blah in bar }} {{ end }} + ╰╴ ++ + +error: Missing `in` in `for .. in ` loop +  ╭▸  +6 │ {{ for blah}} {{ end }} + │ ━━━━ + ╰╴ +note: Try adding it + ╭╴ +6 │ {{ for blah in}} {{ end }} + ╰╴ ++ + +error: Unexpected keyword +  ╭▸  +7 │ {{ else }} + ╰╴ ━━━━ diff --git a/tests/errors/invalid_controls.nomo b/tests/errors/invalid_controls.nomo new file mode 100644 index 0000000..a9d079f --- /dev/null +++ b/tests/errors/invalid_controls.nomo @@ -0,0 +1,9 @@ +{} +--- +{{ if }} {{ end }} +{{ if if }} {{ end }} +{{ if if }} {{ for foo in bar }} {{ end }} {{ end }} +{{ for in bar }} {{ end }} +{{ for blah bar }} {{ end }} +{{ for blah}} {{ end }} +{{ else }} \ No newline at end of file diff --git a/tests/errors/invalid_if.1-parsed.snap b/tests/errors/invalid_if.1-parsed.snap new file mode 100644 index 0000000..7c8ec1a --- /dev/null +++ b/tests/errors/invalid_if.1-parsed.snap @@ -0,0 +1,24 @@ +--- +source: tests/file_tests.rs +expression: parsed +info: + input: "{{ if if }} {{ end }}" + context: {} +--- +ParsedTemplate { + tokens: [ + [LeftDelim]"{{" (0..2), + [Whitespace]" " (2..3), + [ConditionalIf]"if" (3..5), + [Whitespace]" " (5..6), + [ConditionalIf]"if" (6..8), + [Whitespace]" " (8..9), + [RightDelim]"}}" (9..11), + [Whitespace]" " (11..12), + [LeftDelim]"{{" (12..14), + [Whitespace]" " (14..15), + [End]"end" (15..18), + [Whitespace]" " (18..19), + [RightDelim]"}}" (19..21), + ], +} diff --git a/tests/errors/invalid_if.2-ast.snap b/tests/errors/invalid_if.2-ast.snap new file mode 100644 index 0000000..b1781a4 --- /dev/null +++ b/tests/errors/invalid_if.2-ast.snap @@ -0,0 +1,11 @@ +--- +source: tests/file_tests.rs +expression: ast +info: + input: "{{ if if }} {{ end }}" + context: {} +--- +error: Expected an expression after 'if' +  ╭▸  +1 │ {{ if if }} {{ end }} + ╰╴ ━━ diff --git a/tests/errors/invalid_if.nomo b/tests/errors/invalid_if.nomo new file mode 100644 index 0000000..7a492ca --- /dev/null +++ b/tests/errors/invalid_if.nomo @@ -0,0 +1,3 @@ +{} +--- +{{ if if }} {{ end }} \ No newline at end of file diff --git a/tests/file_tests.rs b/tests/file_tests.rs index b26c356..bd3334e 100644 --- a/tests/file_tests.rs +++ b/tests/file_tests.rs @@ -1,11 +1,16 @@ +#![allow(missing_docs)] + use std::collections::HashMap; use std::path::Path; use nomo::Context; use nomo::functions::FunctionMap; +use nomo::input::NomoInput; test_each_file::test_each_path! { for ["nomo"] in "./tests/cases/" as cases => check_for_input } +test_each_file::test_each_path! { for ["nomo"] in "./tests/errors/" as error_cases => check_errors } + #[derive(serde::Serialize)] struct Info { input: String, @@ -39,16 +44,18 @@ fn check_for_input([path]: [&Path; 1]) { context.try_insert(k, v).unwrap(); } - let parsed = nomo::lexer::parse(input.into()).unwrap(); + let input = NomoInput::from(input); + + let parsed = nomo::lexer::parse(input.clone()).unwrap(); let _guard = settings.bind_to_scope(); insta::assert_debug_snapshot!(format!("{basename}.1-parsed"), parsed); - let ast = match nomo::parser::parse(parsed.tokens()) { + let ast = match nomo::parser::parse(input, parsed.tokens()) { Ok(ast) => ast, Err(err) => { - eprintln!("{}", err.to_report(input)); + eprintln!("{}", err); panic!("Could not evaluate ast"); } }; @@ -63,3 +70,69 @@ fn check_for_input([path]: [&Path; 1]) { insta::assert_debug_snapshot!(format!("{basename}.4-output"), output); } + +fn check_errors([path]: [&Path; 1]) { + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_path("errors"); + settings.set_prepend_module_to_snapshot(false); + + let basename = path.file_stem().unwrap().to_string_lossy(); + let input = std::fs::read_to_string(path).unwrap(); + + let (context, input) = input.split_once("\n---\n").unwrap_or_else(|| ("", &input)); + + let map = if !context.is_empty() { + serde_json::from_str::>(context).unwrap() + } else { + HashMap::new() + }; + + settings.set_info(&Info { + input: input.to_string(), + context: map.clone(), + }); + + let mut context = Context::new(); + + for (k, v) in map { + context.try_insert(k, v).unwrap(); + } + + let _guard = settings.bind_to_scope(); + + let input = NomoInput::from(input); + + let parsed = nomo::lexer::parse(input.clone()).map_err(|err| err.to_report()); + + match &parsed { + Ok(parsed) => insta::assert_debug_snapshot!(format!("{basename}.1-parsed"), parsed), + Err(parsed) => insta::assert_snapshot!(format!("{basename}.1-parsed"), parsed), + } + + let Ok(parsed) = parsed else { + return; + }; + + let ast = nomo::parser::parse(input, parsed.tokens()).map_err(|err| err.to_string()); + + match &ast { + Ok(ast) => insta::assert_debug_snapshot!(format!("{basename}.2-ast"), ast), + Err(ast) => insta::assert_snapshot!(format!("{basename}.2-ast"), ast), + } + + let Ok(ast) = ast else { + return; + }; + + let emit = nomo::compiler::emit_machine(ast); + + insta::assert_debug_snapshot!(format!("{basename}.3-instructions"), emit); + + let output = nomo::eval::execute(&FunctionMap::default(), &emit, &context) + .map_err(|err| err.to_string()); + + match &output { + Ok(output) => insta::assert_debug_snapshot!(format!("{basename}.4-output"), output), + Err(output) => insta::assert_snapshot!(format!("{basename}.4-output"), output), + } +}