Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Some love to error handling. #74

Merged
merged 2 commits into from
Jan 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,21 +161,19 @@ Or on an existing Context:
ctx.error_mode = :strict
```

Raises `Liquid::InvalidExpression` on missing keys or `IndexError` on array out of bounds errors instead of silently emitting `nil`.
Raises `Liquid::InvalidExpression` on missing keys and silently emit `nil` on array out of bounds.

Append `?` to emit nil in strict mode (very simplistic, just checks for `?` at the end of the identifier)

```crystal
ctx = Liquid::Context.new(strict: true)
ctx = Liquid::Context.new(:strict)
ctx["obj"] = { something: "something" }
```

```liquid
{{ missing }} -> KeyError
{{ missing? }} -> nil
{{ obj.missing }} -> KeyError
{{ obj.missing? }} -> nil
{{ missing.missing? }} -> nil
{{ missing }} -> nil, but generates a UndefinedVariable errors if not in Lax mode.
{{ obj.missing }} -> InvalidExpression
{{ missing.missing? }} -> generates a UndefinedVariable error, evaluates `missing` to nil then raises a InvalidExpression due to `nil.missing` call.
```

## Contributing
Expand Down
2 changes: 0 additions & 2 deletions spec/expression_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ private def it_raises(exception, message : String, expr : String, ctx : Context,
end

describe Expression do
it_raises(InvalidExpression, "Variable \"bar\" not found", "bar", Context.new(:strict))

it_evaluates("foo", Context{"foo" => 42}, 42)
it_evaluates("foo.blank?", Context{"foo" => ""}, true)
it_evaluates("foo.size", Context{"foo" => Any.new("123")}, 3)
Expand Down
4 changes: 3 additions & 1 deletion spec/integration/integration_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ class GoldenTest
vars = @context.as_h?
raise "Bad context: #{@context.to_s}" if vars.nil?

ctx = Context.new(@strict ? Context::ErrorMode::Strict : Context::ErrorMode::Lax)
# Golden liquid run ruby tests with `render!`, that raises an exception on first error, this is the strict behavior
# of liquid crystal.
ctx = Context.new(@strict || @error ? Context::ErrorMode::Strict : Context::ErrorMode::Lax)
vars.each do |key, value|
ctx.set(key.as_s, yaml_any_to_liquid_any(value))
end
Expand Down
20 changes: 11 additions & 9 deletions spec/liquid_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,23 @@ describe Liquid::Context do
ctx["missing"]?.should be_nil
end

it "raises on missing key in strict mode" do
ctx = Context.new(:strict)
ctx["obj"] = Any{"something" => "something"}
expect_raises(InvalidExpression) { ctx.get("missing") }
expect_raises(InvalidExpression) { ctx.get("obj.missing") }
it "returns nil for undefined variables on Lax mode" do
ctx = Context.new(:lax)
ctx.get("missing").raw.should eq(nil)
ctx.errors.should be_empty
end

it "returns nil for missing key on Lax mode" do
ctx = Context.new(:lax)
it "does not raise for undefined variables on strict mode" do
ctx = Context.new(:strict)
ctx.get("missing").raw.should eq(nil)
ctx.errors.map(&.message).should eq([%(Liquid error: Undefined variable: "missing".)])
ctx.errors.map(&.class).should eq([Liquid::UndefinedVariable])
end

it "returns nil for missing key on Warn mode" do
it "store errors for undefined variables in warn mode" do
ctx = Context.new(:warn)
ctx.get("missing").raw.should eq(nil)
ctx.errors.should eq([%(Variable "missing" not found.)])
ctx.errors.map(&.message).should eq([%(Liquid error: Undefined variable: "missing".)])
ctx.errors.map(&.class).should eq([Liquid::UndefinedVariable])
end
end
1 change: 1 addition & 0 deletions spec/template_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe Template do
it_renders("{% if 'a' != blank %}noblank{% endif %}", "noblank")
it_renders("{% if '' > blank %}blank{% endif %}", "")
it_renders("{% assign a = '' | split %}{% if a == blank %}blank{% endif %}", "blank")
it_renders("{{ a[100] }}", "", Context.new(:strict, {"a" => Any{1, 2}}))

it "should render raw text" do
tpl = Parser.parse("raw text")
Expand Down
40 changes: 29 additions & 11 deletions src/liquid/context.cr
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,29 @@ require "./blank"

module Liquid
class Context
# Context error mode.
enum ErrorMode
# Raise exceptions on any error but `UndefinedVariable`, that can be accessed later by `Context#errors`.
Strict
# Like `Lax` mode, but the errors can be accessed later by `Context#errors`.
Warn
# Do the best to render something without raising any exceptions.
Lax
end

@data = Hash(String, Any).new
@data : Hash(String, Any)

# :nodoc:
# These values are used/reused when calling filters in a expression using this context.
protected getter filter_args = Array(Any).new
# :nodoc:
protected getter filter_options = Hash(String, Any).new(Any.new)

property error_mode : ErrorMode
getter errors = Array(String).new
# List of errors found when rendering using this context.
getter errors = Array(LiquidException).new

def initialize(@error_mode = :lax)
def initialize(@error_mode = :lax, @data = Hash(String, Any).new)
add_builtins
end

Expand All @@ -29,6 +35,7 @@ module Liquid

@[Deprecated("Use `initialize(ErrorMode)` instead.")]
def initialize(strict : Bool)
@data = Hash(String, Any).new
@error_mode = strict ? ErrorMode::Strict : ErrorMode::Lax
add_builtins
end
Expand All @@ -54,24 +61,35 @@ module Liquid
value
end

protected def add_error(error : LiquidException) : Any
raise error if @error_mode.strict?

@errors << error if @error_mode.warn?
Any.new(nil)
end

protected def add_error(error : UndefinedVariable) : Any
@errors << error if @error_mode.warn? || @error_mode.strict?
Any.new(nil)
end

# Fetch a variable from context, add `UndefinedVariable` error if the variable isn't found and behave according the
# error mode.
def get(var : String) : Any
value = @data[var]?
return value if value

if !@error_mode.lax?
error_message = "Variable \"#{var}\" not found."
raise InvalidExpression.new(error_message) if @error_mode.strict?

@errors << error_message if @error_mode.warn?
end

Any.new(nil)
add_error(UndefinedVariable.new(var))
end

# Alias for `#get`
def [](var : String) : Any
get(var)
end

# Fetch a variable from context and return nil if the variable isn't found.
#
# This doesn't trigger any exceptions or store any errors if the variable doesn't exists.
def []?(var : String) : Any?
@data[var]?
end
Expand Down
32 changes: 28 additions & 4 deletions src/liquid/exceptions.cr
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
# exceptions.cr

module Liquid
# Base class for all exceptions raised by Liquid.cr shard.
class LiquidException < Exception
end

# Exception raised on syntax errors.
class SyntaxError < LiquidException
# Line number where the syntax error was found.
property line_number : Int32 = -1
end

class InvalidStatement < LiquidException
end

# Exception used for any non-fatal errors that can happen while rendering a liquid template.
class InvalidExpression < LiquidException
def initialize(message : String)
super("Liquid error: #{message}")
end
end

class InvalidStatement < LiquidException
# Error generated when a variable used in a template doesn't exists in the current context.
#
# This exception is never raised whatever the context error mode, to access it check `Context#errors`.
class UndefinedVariable < InvalidExpression
def initialize(var_name : String)
super("Undefined variable: \"#{var_name}\".")
end
end

# Error generated when a filter used in a template doesn't exists.
#
# This exception is only raised in `Context::ErrorMode::Strict` error mode.
class UndefinedFilter < InvalidExpression
def initialize(filter_name : String)
super("Undefined filter: #{filter_name}")
end
end

class FilterArgumentException < LiquidException
# Exception raised by filters if something went wrong.
class FilterArgumentException < InvalidExpression
end
end
Loading