diff --git a/rslib/src/backend.rs b/rslib/src/backend.rs index 44f20e2ec..63d39f960 100644 --- a/rslib/src/backend.rs +++ b/rslib/src/backend.rs @@ -25,9 +25,7 @@ impl std::convert::From for pt::BackendError { use pt::backend_error::Value as V; let value = match err { AnkiError::InvalidInput { info } => V::InvalidInput(pt::InvalidInputError { info }), - AnkiError::TemplateParseError { info } => { - V::TemplateParse(pt::TemplateParseError { info }) - } + AnkiError::TemplateError { info } => V::TemplateParse(pt::TemplateParseError { info }), }; pt::BackendError { value: Some(value) } diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 919b1da8c..d57eee395 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -11,7 +11,7 @@ pub enum AnkiError { InvalidInput { info: String }, #[fail(display = "invalid card template: {}", info)] - TemplateParseError { info: String }, + TemplateError { info: String }, } // error helpers @@ -21,24 +21,29 @@ impl AnkiError { } } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum TemplateError { NoClosingBrackets(String), ConditionalNotClosed(String), ConditionalNotOpen(String), + FieldNotFound(String), } impl From for AnkiError { fn from(terr: TemplateError) -> Self { - AnkiError::TemplateParseError { + AnkiError::TemplateError { info: match terr { TemplateError::NoClosingBrackets(context) => { - format!("expected '{{{{field name}}}}', found '{}'", context) + format!("missing '}}}}' in '{}'", context) } TemplateError::ConditionalNotClosed(tag) => format!("missing '{{{{/{}}}}}'", tag), TemplateError::ConditionalNotOpen(tag) => { format!("missing '{{{{#{}}}}}' or '{{{{^{}}}}}'", tag, tag) } + TemplateError::FieldNotFound(field) => format!( + "found '{{{{{}}}}}', but there is no field called '{}'", + field, field + ), }, } } diff --git a/rslib/src/template.rs b/rslib/src/template.rs index 3c0b98b2d..de22b4f81 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -16,6 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::iter; pub type FieldMap<'a> = HashMap<&'a str, u16>; +type TemplateResult = std::result::Result; // Lexing //---------------------------------------- @@ -87,7 +88,7 @@ fn next_token(input: &str) -> nom::IResult<&str, Token> { alt((handle_token, text_token))(input) } -fn tokens(template: &str) -> impl Iterator> { +fn tokens(template: &str) -> impl Iterator> { let mut data = template; std::iter::from_fn(move || { @@ -132,16 +133,16 @@ impl ParsedTemplate<'_> { /// /// The legacy alternate syntax is not supported, so the provided text /// should be run through without_legacy_template_directives() first. - pub fn from_text(template: &str) -> std::result::Result { + pub fn from_text(template: &str) -> TemplateResult { let mut iter = tokens(template); Ok(Self(parse_inner(&mut iter, None)?)) } } -fn parse_inner<'a, I: Iterator, TemplateError>>>( +fn parse_inner<'a, I: Iterator>>>( iter: &mut I, open_tag: Option<&'a str>, -) -> std::result::Result>, TemplateError> { +) -> TemplateResult>> { let mut nodes = vec![]; while let Some(token) = iter.next() { @@ -275,12 +276,12 @@ impl ParsedTemplate<'_> { /// Replacements that use only standard filters will become part of /// a text node. If a non-standard filter is encountered, a partially /// rendered Replacement is returned for the calling code to complete. - fn render(&self, context: &RenderContext) -> Vec { + fn render(&self, context: &RenderContext) -> TemplateResult> { let mut rendered = vec![]; - render_into(&mut rendered, self.0.as_ref(), context); + render_into(&mut rendered, self.0.as_ref(), context)?; - rendered + Ok(rendered) } } @@ -288,7 +289,7 @@ fn render_into( rendered_nodes: &mut Vec, nodes: &[ParsedNode], context: &RenderContext, -) { +) -> TemplateResult<()> { use ParsedNode::*; for node in nodes { match node { @@ -327,7 +328,17 @@ fn render_into( // apply built in filters if field exists let (text, remaining_filters) = match context.fields.get(key) { Some(text) => apply_filters(text, filters, key, context), - None => (unknown_field_message(key, filters).into(), vec![]), + None => { + // unknown field encountered + let name_including_filters = filters + .iter() + .rev() + .cloned() + .chain(iter::once(*key)) + .collect::>() + .join(":"); + return Err(TemplateError::FieldNotFound(name_including_filters)); + } }; // fully processed? @@ -343,16 +354,18 @@ fn render_into( } Conditional { key, children } => { if context.nonempty_fields.contains(key) { - render_into(rendered_nodes, children.as_ref(), context); + render_into(rendered_nodes, children.as_ref(), context)?; } } NegatedConditional { key, children } => { if !context.nonempty_fields.contains(key) { - render_into(rendered_nodes, children.as_ref(), context); + render_into(rendered_nodes, children.as_ref(), context)?; } } }; } + + Ok(()) } /// Append to last node if last node is a string, else add new node. @@ -401,19 +414,6 @@ fn nonempty_fields<'a>(fields: &'a HashMap<&str, &str>) -> HashSet<&'a str> { .collect() } -fn unknown_field_message(field_name: &str, filters: &[&str]) -> String { - format!( - "{{unknown field {}}}", - filters - .iter() - .rev() - .cloned() - .chain(iter::once(field_name)) - .collect::>() - .join(":") - ) -} - // Rendering both sides //---------------------------------------- @@ -435,7 +435,7 @@ pub fn render_card( // question side let qnorm = without_legacy_template_directives(qfmt); - let qnodes = ParsedTemplate::from_text(qnorm.as_ref())?.render(&context); + let qnodes = ParsedTemplate::from_text(qnorm.as_ref())?.render(&context)?; // if the question side didn't have any unknown filters, we can pass // FrontSide in now @@ -446,7 +446,7 @@ pub fn render_card( // answer side context.question_side = false; let anorm = without_legacy_template_directives(afmt); - let anodes = ParsedTemplate::from_text(anorm.as_ref())?.render(&context); + let anodes = ParsedTemplate::from_text(anorm.as_ref())?.render(&context)?; Ok((qnodes, anodes)) } @@ -510,6 +510,7 @@ impl ParsedTemplate<'_> { #[cfg(test)] mod test { use super::{FieldMap, ParsedNode::*, ParsedTemplate as PT}; + use crate::err::TemplateError; use crate::template::{ field_is_empty, nonempty_fields, render_card, without_legacy_template_directives, FieldRequirements, RenderContext, RenderedNode, @@ -661,7 +662,7 @@ mod test { use crate::template::RenderedNode as FN; let mut tmpl = PT::from_text("{{B}}A{{F}}").unwrap(); assert_eq!( - tmpl.render(&ctx), + tmpl.render(&ctx).unwrap(), vec![FN::Text { text: "bAf".to_owned() },] @@ -669,12 +670,12 @@ mod test { // empty tmpl = PT::from_text("{{#E}}A{{/E}}").unwrap(); - assert_eq!(tmpl.render(&ctx), vec![]); + assert_eq!(tmpl.render(&ctx).unwrap(), vec![]); // missing tmpl = PT::from_text("{{^M}}A{{/M}}").unwrap(); assert_eq!( - tmpl.render(&ctx), + tmpl.render(&ctx).unwrap(), vec![FN::Text { text: "A".to_owned() },] @@ -683,7 +684,7 @@ mod test { // nested tmpl = PT::from_text("{{^E}}1{{#F}}2{{#B}}{{F}}{{/B}}{{/F}}{{/E}}").unwrap(); assert_eq!( - tmpl.render(&ctx), + tmpl.render(&ctx).unwrap(), vec![FN::Text { text: "12f".to_owned() },] @@ -692,7 +693,7 @@ mod test { // unknown filters tmpl = PT::from_text("{{one:two:B}}").unwrap(); assert_eq!( - tmpl.render(&ctx), + tmpl.render(&ctx).unwrap(), vec![FN::Replacement { field_name: "B".to_owned(), filters: vec!["two".to_string(), "one".to_string()], @@ -704,7 +705,7 @@ mod test { // excess colons are ignored tmpl = PT::from_text("{{one::text:B}}").unwrap(); assert_eq!( - tmpl.render(&ctx), + tmpl.render(&ctx).unwrap(), vec![FN::Replacement { field_name: "B".to_owned(), filters: vec!["one".to_string()], @@ -715,7 +716,7 @@ mod test { // known filter tmpl = PT::from_text("{{text:B}}").unwrap(); assert_eq!( - tmpl.render(&ctx), + tmpl.render(&ctx).unwrap(), vec![FN::Text { text: "b".to_owned() }] @@ -724,25 +725,21 @@ mod test { // unknown field tmpl = PT::from_text("{{X}}").unwrap(); assert_eq!( - tmpl.render(&ctx), - vec![FN::Text { - text: "{unknown field X}".to_owned() - }] + tmpl.render(&ctx).unwrap_err(), + TemplateError::FieldNotFound("X".to_owned()) ); // unknown field with filters tmpl = PT::from_text("{{foo:text:X}}").unwrap(); assert_eq!( - tmpl.render(&ctx), - vec![FN::Text { - text: "{unknown field foo:text:X}".to_owned() - }] + tmpl.render(&ctx).unwrap_err(), + TemplateError::FieldNotFound("foo:text:X".to_owned()) ); // a blank field is allowed if it has filters tmpl = PT::from_text("{{filter:}}").unwrap(); assert_eq!( - tmpl.render(&ctx), + tmpl.render(&ctx).unwrap(), vec![FN::Replacement { field_name: "".to_string(), current_text: "".to_string(),