From 948ccc8f2166ff8ed740ab1d1fdcbc14db045129 Mon Sep 17 00:00:00 2001 From: Craig Blanchette Date: Wed, 1 Apr 2026 23:23:39 -0400 Subject: [PATCH] Working on more parity --- .../expression_parser/infix/assignment.rb | 48 +++++++++++++ lib/twig/expression_parser/infix/dot.rb | 2 +- .../infix/parses_arguments.rb | 7 +- lib/twig/expression_parser/prefix/literal.rb | 12 +--- lib/twig/expression_parser/prefix/unary.rb | 5 +- lib/twig/extension/core.rb | 7 +- lib/twig/lexer.rb | 2 +- lib/twig/node/expression/array.rb | 8 +++ lib/twig/node/expression/binary/base.rb | 2 + .../binary/object_destructuring_set_binary.rb | 61 ++++++++++++++++ .../sequence_destructuring_set_binary.rb | 50 +++++++++++++ lib/twig/node/expression/binary/set_binary.rb | 22 ++++++ lib/twig/node/expression/empty.rb | 17 +++++ lib/twig/node_visitor/safe_analysis.rb | 5 ++ lib/twig/runtime/escaper.rb | 40 +++++++++++ lib/twig/token_stream.rb | 2 +- .../object_destructuring_set_binary_spec.rb | 70 +++++++++++++++++++ .../node/expression/binary/same_as_spec.rb | 30 ++++++++ .../sequence_destructuring_set_binary_spec.rb | 54 ++++++++++++++ .../node/expression/binary/set_binary_spec.rb | 54 ++++++++++++++ .../twig/node/expression/empty_slot_spec.rb | 14 ++++ spec/lib/twig/runtime/escaper_spec.rb | 38 ++++++++++ test/fixtures/expressions/set.test.rb | 23 ++++++ .../spread_ternary_precedence.test.rb | 12 ++++ .../filters/escape_html_attr_relaxed.test.rb | 12 ++++ 25 files changed, 580 insertions(+), 17 deletions(-) create mode 100644 lib/twig/expression_parser/infix/assignment.rb create mode 100644 lib/twig/node/expression/binary/object_destructuring_set_binary.rb create mode 100644 lib/twig/node/expression/binary/sequence_destructuring_set_binary.rb create mode 100644 lib/twig/node/expression/binary/set_binary.rb create mode 100644 lib/twig/node/expression/empty.rb create mode 100644 spec/lib/twig/node/expression/binary/object_destructuring_set_binary_spec.rb create mode 100644 spec/lib/twig/node/expression/binary/same_as_spec.rb create mode 100644 spec/lib/twig/node/expression/binary/sequence_destructuring_set_binary_spec.rb create mode 100644 spec/lib/twig/node/expression/binary/set_binary_spec.rb create mode 100644 spec/lib/twig/node/expression/empty_slot_spec.rb create mode 100644 test/fixtures/expressions/set.test.rb create mode 100644 test/fixtures/expressions/spread_ternary_precedence.test.rb create mode 100644 test/fixtures/filters/escape_html_attr_relaxed.test.rb diff --git a/lib/twig/expression_parser/infix/assignment.rb b/lib/twig/expression_parser/infix/assignment.rb new file mode 100644 index 00000000..8a74ecfc --- /dev/null +++ b/lib/twig/expression_parser/infix/assignment.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Twig + module ExpressionParser + module Infix + class Assignment < InfixExpressionParser + def parse(parser, left, token) + right = parser.parse_expression(precedence) + + case left + when Node::Expression::Array + return Node::Expression::Binary::SequenceDestructuringSetBinary.new(left, right, token.lineno) + when Node::Expression::Hash + return Node::Expression::Binary::ObjectDestructuringSetBinary.new(left, right, token.lineno) + when Node::Expression::Variable::Context + return Node::Expression::Binary::SetBinary.new(left, right, token.lineno) + end + + raise Error::Syntax.new( + "Cannot assign to \"#{left.class}\", only variables can be assigned.", + token.lineno, + parser.stream.source + ) + end + + def name + '=' + end + + def description + 'Assignment operator' + end + + def precedence + 0 + end + + def associativity + RIGHT + end + + def aliases + [] + end + end + end + end +end diff --git a/lib/twig/expression_parser/infix/dot.rb b/lib/twig/expression_parser/infix/dot.rb index 3a384e29..697d589c 100644 --- a/lib/twig/expression_parser/infix/dot.rb +++ b/lib/twig/expression_parser/infix/dot.rb @@ -24,7 +24,7 @@ def parse(parser, left, _token) attribute = Node::Expression::Constant.new(token.value, token.lineno) else raise Error::Syntax.new( - "Expected name or number, got value \"#{token.value}\" of type #{token.type}.", + "Expected name or number, got value \"#{token.value}\" of type \"#{token.to_english}\".", token.lineno, stream.source ) diff --git a/lib/twig/expression_parser/infix/parses_arguments.rb b/lib/twig/expression_parser/infix/parses_arguments.rb index 00c41fe1..a9d800e9 100644 --- a/lib/twig/expression_parser/infix/parses_arguments.rb +++ b/lib/twig/expression_parser/infix/parses_arguments.rb @@ -50,8 +50,11 @@ def parse_named_arguments(parser, parse_open_parenthesis: true) end name = nil - if (token = stream.next_if(Token::OPERATOR_TYPE, '=')) || - (token = stream.next_if(Token::PUNCTUATION_TYPE, ':')) + if value.is_a?(Node::Expression::Binary::SetBinary) + name = value.nodes[:left].attributes[:name] + value = value.nodes[:right] + elsif (token = stream.next_if(Token::OPERATOR_TYPE, '=')) || + (token = stream.next_if(Token::PUNCTUATION_TYPE, ':')) # Allow quoted kwargs - form_with("data-turbo-stream": true) if value.is_a?(Node::Expression::Constant) && value.attributes[:value].is_a?(String) name = value.attributes[:value] diff --git a/lib/twig/expression_parser/prefix/literal.rb b/lib/twig/expression_parser/prefix/literal.rb index 9259154e..c6a0227b 100644 --- a/lib/twig/expression_parser/prefix/literal.rb +++ b/lib/twig/expression_parser/prefix/literal.rb @@ -77,14 +77,6 @@ def parse(parser, token) return Node::Expression::Variable::Context.new(token.value, token.lineno) end - if token.value == '=' && %w[== !=].include?(parser.stream.look(-1).value) - raise Error::Syntax.new( - "Unexpected operator of value \"#{token.value}\". Did you try to use \"===\" or \"!==\" for " \ - 'strict comparison? Use "is same as(value)" instead.', - token.lineno, - parser.stream.source - ) - end end raise Error::Syntax.new( @@ -155,7 +147,9 @@ def parse_sequence_expression(parser) first = false - if stream.next_if(Token::OPERATOR_TYPE, '...') + if stream.test(Token::PUNCTUATION_TYPE, ',') || stream.test(Token::PUNCTUATION_TYPE, ']') + node.add_element(Node::Expression::EmptySlot.new(stream.current.lineno)) + elsif stream.next_if(Token::OPERATOR_TYPE, '...') expr = parser.parse_expression node.add_element(Node::Expression::Unary::ArraySpread.new(expr, expr.lineno)) else diff --git a/lib/twig/expression_parser/prefix/unary.rb b/lib/twig/expression_parser/prefix/unary.rb index 1b6764b2..1fa0822e 100644 --- a/lib/twig/expression_parser/prefix/unary.rb +++ b/lib/twig/expression_parser/prefix/unary.rb @@ -6,18 +6,19 @@ module Prefix class Unary < PrefixExpressionParser attr_reader :aliases, :precedence, :name - def initialize(node_class, name, precedence, description: nil, aliases: []) + def initialize(node_class, name, precedence, description: nil, aliases: [], operand_precedence: nil) super() @node_class = node_class @name = name @precedence = precedence + @operand_precedence = operand_precedence @description = description @aliases = aliases end def parse(parser, token) - @node_class.new(parser.parse_expression(precedence), token.lineno) + @node_class.new(parser.parse_expression(@operand_precedence || precedence), token.lineno) end def description diff --git a/lib/twig/extension/core.rb b/lib/twig/extension/core.rb index 2fc9b5fd..703c2b9e 100644 --- a/lib/twig/extension/core.rb +++ b/lib/twig/extension/core.rb @@ -25,7 +25,7 @@ def expression_parsers [ # Unary operators unary.new(Node::Expression::Unary::Not, 'not', 70), - unary.new(Node::Expression::Unary::Spread, '...', 512, description: 'Spread Operator'), + unary.new(Node::Expression::Unary::Spread, '...', 512, description: 'Spread Operator', operand_precedence: 0), unary.new(Node::Expression::Unary::Neg, '-', 500), unary.new(Node::Expression::Unary::Pos, '+', 500), @@ -46,6 +46,8 @@ def expression_parsers binary.new(Node::Expression::Binary::BitwiseAnd, 'b-and', 16), binary.new(Node::Expression::Binary::Equal, '==', 20), binary.new(Node::Expression::Binary::NotEqual, '!=', 20), + binary.new(Node::Expression::Binary::SameAs, '===', 20), + binary.new(Node::Expression::Binary::NotSameAs, '!==', 20), binary.new(Node::Expression::Binary::Spaceship, '<=>', 20), binary.new(Node::Expression::Binary::Less, '<', 20), binary.new(Node::Expression::Binary::Greater, '>', 20), @@ -87,6 +89,9 @@ def expression_parsers # Arrow function ExpressionParser::Infix::Arrow.new, + # Assignment operator + ExpressionParser::Infix::Assignment.new, + # All literals ExpressionParser::Prefix::Literal.new, ] diff --git a/lib/twig/lexer.rb b/lib/twig/lexer.rb index 356e5359..39bd0182 100644 --- a/lib/twig/lexer.rb +++ b/lib/twig/lexer.rb @@ -457,7 +457,7 @@ def lex_raw_data_regex def operator_regex return @operator_regex if defined?(@operator_regex) - expression_parsers = ['='] + expression_parsers = [] environment.expression_parsers.each do |expression_parser| expression_parsers.concat([expression_parser.name], expression_parser.aliases) diff --git a/lib/twig/node/expression/array.rb b/lib/twig/node/expression/array.rb index 12139dea..979d428b 100644 --- a/lib/twig/node/expression/array.rb +++ b/lib/twig/node/expression/array.rb @@ -24,6 +24,14 @@ def compile(compiler) return compiler.repr(true) end + # Empty expressions are only valid in destructuring contexts + values.each do |value| + if value.is_a?(Expression::EmptySlot) + raise Error::Syntax.new('Empty array elements are only allowed in destructuring assignments.', + value.lineno) + end + end + compiler. raw('['). indent diff --git a/lib/twig/node/expression/binary/base.rb b/lib/twig/node/expression/binary/base.rb index d450f321..6661df85 100644 --- a/lib/twig/node/expression/binary/base.rb +++ b/lib/twig/node/expression/binary/base.rb @@ -49,6 +49,8 @@ def operator(compiler) Div: '/', Mod: '%', Power: '**', + SameAs: '==', + NotSameAs: '!=', }.freeze # Lots of simple operator classes can just be generated dynamically diff --git a/lib/twig/node/expression/binary/object_destructuring_set_binary.rb b/lib/twig/node/expression/binary/object_destructuring_set_binary.rb new file mode 100644 index 00000000..8f0cb8fa --- /dev/null +++ b/lib/twig/node/expression/binary/object_destructuring_set_binary.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Twig + module Node + module Expression + module Binary + class ObjectDestructuringSetBinary < Binary::Base + def initialize(left, right, lineno) + @mappings = [] + + left.key_value_pairs.each do |key, value| + unless value.is_a?(Variable::Context) + raise Error::Syntax.new( + "Cannot assign to \"#{value.class}\", only variables can be assigned in " \ + 'object/mapping destructuring.', + lineno + ) + end + + @mappings << { + property: key.attributes[:value], + variable: value.attributes[:name], + } + end + + super + end + + def compile(compiler) + compiler.add_debug_info(self) + + @mappings.each_with_index do |mapping, i| + compiler.raw(', ') if i.positive? + compiler.raw('context[').string(mapping[:variable]).raw(']') + end + + compiler.raw(' = ') + + @mappings.each_with_index do |mapping, i| + compiler.raw(', ') if i.positive? + compiler. + raw('::Twig::Extension::Core.get_attribute(env, source_context, '). + subcompile(nodes[:right]). + raw(', '). + repr(mapping[:property]). + raw(', '). + repr(Template::ANY_CALL). + raw(', lineno: '). + repr(nodes[:right].lineno). + raw(')') + end + end + + def operator(compiler) + compiler.raw('=') + end + end + end + end + end +end diff --git a/lib/twig/node/expression/binary/sequence_destructuring_set_binary.rb b/lib/twig/node/expression/binary/sequence_destructuring_set_binary.rb new file mode 100644 index 00000000..b56cb7b5 --- /dev/null +++ b/lib/twig/node/expression/binary/sequence_destructuring_set_binary.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Twig + module Node + module Expression + module Binary + class SequenceDestructuringSetBinary < Binary::Base + def initialize(left, right, lineno) + @variables = [] + + left.each_value do |value| + if value.is_a?(Expression::EmptySlot) + @variables << nil + elsif value.is_a?(Variable::Context) + @variables << value.attributes[:name] + else + raise Error::Syntax.new( + "Cannot assign to \"#{value.class}\", only variables can be assigned in destructuring.", + lineno + ) + end + end + + super + end + + def compile(compiler) + compiler.add_debug_info(self) + + @variables.each_with_index do |name, i| + compiler.raw(', ') if i.positive? + if name + compiler.raw('context[').string(name).raw(']') + else + compiler.raw('_') + end + end + + compiler.raw(' = *(').subcompile(nodes[:right]).raw(' + ::Array.new(') + compiler.repr(@variables.length).raw('))') + end + + def operator(compiler) + compiler.raw('=') + end + end + end + end + end +end diff --git a/lib/twig/node/expression/binary/set_binary.rb b/lib/twig/node/expression/binary/set_binary.rb new file mode 100644 index 00000000..d33c8706 --- /dev/null +++ b/lib/twig/node/expression/binary/set_binary.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Twig + module Node + module Expression + module Binary + class SetBinary < Binary::Base + def initialize(left, right, lineno) + name = left.attributes[:name] + left = Variable::AssignContext.new(name, left.lineno) + + super + end + + def operator(compiler) + compiler.raw('=') + end + end + end + end + end +end diff --git a/lib/twig/node/expression/empty.rb b/lib/twig/node/expression/empty.rb new file mode 100644 index 00000000..fc25bb89 --- /dev/null +++ b/lib/twig/node/expression/empty.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Twig + module Node + module Expression + class EmptySlot < Expression::Base + def initialize(lineno) + super({}, {}, lineno) + end + + def compile(compiler) + # Empty slot compiles to nothing + end + end + end + end +end diff --git a/lib/twig/node_visitor/safe_analysis.rb b/lib/twig/node_visitor/safe_analysis.rb index 785b0728..9c5d98e5 100644 --- a/lib/twig/node_visitor/safe_analysis.rb +++ b/lib/twig/node_visitor/safe_analysis.rb @@ -73,6 +73,11 @@ def safe(node) if bucket[:value].include?(:html_attr) bucket[:value] << :html + bucket[:value] << :html_attr_relaxed + end + + if bucket[:value].include?(:html_attr_relaxed) + bucket[:value] << :html end return bucket[:value] diff --git a/lib/twig/runtime/escaper.rb b/lib/twig/runtime/escaper.rb index cd5f7758..01c547fd 100644 --- a/lib/twig/runtime/escaper.rb +++ b/lib/twig/runtime/escaper.rb @@ -28,6 +28,8 @@ def escape(string, strategy = :html, charset = nil, autoescape = false) CGI.escapeHTML(string.to_s) when :html_attr escape_html_attr(string.to_s, charset || @charset) + when :html_attr_relaxed + escape_html_attr_relaxed(string.to_s, charset || @charset) when :js escape_js(string.to_s, charset || @charset) when :css @@ -83,6 +85,44 @@ def escape_html_attr(string, charset) string end + def escape_html_attr_relaxed(string, charset) + # Convert encoding if needed + if charset != 'UTF-8' + string = convert_encoding(string, 'UTF-8', charset) + end + + # Validate UTF-8 + unless string.valid_encoding? + raise Error::Runtime, 'The string to escape is not a valid UTF-8 string.' + end + + # Less restrictive than html_attr - also allows :, @, [, and ] + string = string.gsub(/[^a-zA-Z0-9,.\-_:@\[\]]/u) do |char| + ord = char.ord + + if (ord <= 0x1F && char != "\t" && char != "\n" && char != "\r") || ord.between?(0x7F, 0x9F) + '�' + elsif char.bytesize == 1 + case ord + when 34 then '"' + when 38 then '&' + when 60 then '<' + when 62 then '>' + else + format('&#x%02X;', ord) + end + else + format('&#x%04X;', char.codepoints.first) + end + end + + if charset != 'UTF-8' + string = string.encode(charset, 'UTF-8') + end + + string + end + def escape_js(string, charset) # Convert encoding if needed if charset != 'UTF-8' diff --git a/lib/twig/token_stream.rb b/lib/twig/token_stream.rb index 3e5cc5a1..f14d140c 100644 --- a/lib/twig/token_stream.rb +++ b/lib/twig/token_stream.rb @@ -41,7 +41,7 @@ def expect(type, value = nil, message = nil) unless token.test(type, value) expected = Token.type_to_english(type) unexpected = Token.type_to_english(token.type) - token_value = token.value.empty? ? '' : " of value \"#{token.value}\"" + token_value = token.value.to_s.empty? ? '' : " of value \"#{token.value}\"" value = " with value \"#{value}\"" if value message = "#{message} " if message diff --git a/spec/lib/twig/node/expression/binary/object_destructuring_set_binary_spec.rb b/spec/lib/twig/node/expression/binary/object_destructuring_set_binary_spec.rb new file mode 100644 index 00000000..d790d1db --- /dev/null +++ b/spec/lib/twig/node/expression/binary/object_destructuring_set_binary_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'integration_shared_examples' + +RSpec.describe Twig::Node::Expression::Binary::ObjectDestructuringSetBinary do + describe 'hash destructuring' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do {first_name, last_name} = user %}{{ first_name }} {{ last_name }}' } + let(:output) { 'Fabien Potencier' } + let(:locals) { { user: { first_name: 'Fabien', last_name: 'Potencier' } } } + end + end + + describe 'hash destructuring with string keys' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do {name, email} = user %}{{ name }} {{ email }}' } + let(:output) { 'Fabien fabien@example.com' } + let(:locals) { { user: { 'name' => 'Fabien', 'email' => 'fabien@example.com' } } } + end + end + + describe 'object destructuring via method access' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do {name} = user %}{{ name }}' } + let(:output) { 'Fabien' } + let(:locals) do + user = Object.new + user.define_singleton_method(:name) { 'Fabien' } + { user: } + end + end + end + + describe 'renaming variables during destructuring' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do {name: user_name, email: user_email} = user %}{{ user_name }} {{ user_email }}' } + let(:output) { 'Fabien fabien@example.com' } + let(:locals) { { user: { 'name' => 'Fabien', 'email' => 'fabien@example.com' } } } + end + end + + describe 'renaming with method access' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do {name: obj_name} = user %}{{ obj_name }}' } + let(:output) { 'Fabien' } + let(:locals) do + user = Object.new + user.define_singleton_method(:name) { 'Fabien' } + { user: } + end + end + end + + describe 'multiple destructuring statements' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do {name: n1} = a %}{% do {name: n2} = b %}{{ n1 }} {{ n2 }}' } + let(:output) { 'Alice Bob' } + let(:locals) { { a: { 'name' => 'Alice' }, b: { 'name' => 'Bob' } } } + end + end + + context 'when destructuring to a non-variable' do + it_behaves_like 'render_and_raise' do + let(:template) { '{% do {name: "literal"} = user %}' } + let(:error) { Twig::Error::Syntax } + let(:message) { %r{only variables can be assigned in object/mapping destructuring} } + end + end +end diff --git a/spec/lib/twig/node/expression/binary/same_as_spec.rb b/spec/lib/twig/node/expression/binary/same_as_spec.rb new file mode 100644 index 00000000..4f9a3aeb --- /dev/null +++ b/spec/lib/twig/node/expression/binary/same_as_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'integration_shared_examples' + +RSpec.describe Twig::Node::Expression::Binary::SameAs do + it_behaves_like 'render_and_assert' do + let(:inputs) do + <<~INPUTS + {{ 1 === 1 ? "OK" }} + {{ 1 !== true ? "OK" }} + {{ "a" === "a" ? "OK" }} + {{ null === null ? "OK" }} + {{ 1 !== "1" ? "OK" }} + INPUTS + end + + let(:outputs) do + <<~OUTPUTS + OK + OK + OK + OK + OK + OUTPUTS + end + + let(:locals) { {} } + end +end diff --git a/spec/lib/twig/node/expression/binary/sequence_destructuring_set_binary_spec.rb b/spec/lib/twig/node/expression/binary/sequence_destructuring_set_binary_spec.rb new file mode 100644 index 00000000..7d2d728d --- /dev/null +++ b/spec/lib/twig/node/expression/binary/sequence_destructuring_set_binary_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'integration_shared_examples' + +RSpec.describe Twig::Node::Expression::Binary::SequenceDestructuringSetBinary do + describe 'basic array destructuring' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do [first, last] = names %}{{ first }} {{ last }}' } + let(:output) { 'Fabien Potencier' } + let(:locals) { { names: %w[Fabien Potencier] } } + end + end + + describe 'swap values' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do a = "x" %}{% do b = "y" %}{% do [a, b] = [b, a] %}{{ a }}{{ b }}' } + let(:output) { 'yx' } + let(:locals) { {} } + end + end + + describe 'skipping elements with empty slots' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do [, second] = items %}{{ second }}' } + let(:output) { 'second' } + let(:locals) { { items: %w[first second] } } + end + end + + describe 'destructuring with fewer values pads with nil' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do [x, y, z] = items %}{{ x }} {{ y }} {{ z is same as(null) ? "null" : z }}' } + let(:output) { 'one two null' } + let(:locals) { { items: %w[one two] } } + end + end + + describe 'destructuring from inline array' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do [a, b, c] = [1, 2, 3] %}{{ a }}{{ b }}{{ c }}' } + let(:output) { '123' } + let(:locals) { {} } + end + end + + context 'when destructuring to a non-variable' do + it_behaves_like 'render_and_raise' do + let(:template) { '{% do [1, b] = [1, 2] %}' } + let(:error) { Twig::Error::Syntax } + let(:message) { /only variables can be assigned in destructuring/ } + end + end +end diff --git a/spec/lib/twig/node/expression/binary/set_binary_spec.rb b/spec/lib/twig/node/expression/binary/set_binary_spec.rb new file mode 100644 index 00000000..ba57a888 --- /dev/null +++ b/spec/lib/twig/node/expression/binary/set_binary_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'integration_shared_examples' + +RSpec.describe Twig::Node::Expression::Binary::SetBinary do + describe 'simple assignment' do + it_behaves_like 'render_and_assert' do + let(:inputs) do + <<~INPUTS + {% do b = 1 + 3 %}{{ b }} + {{ b = 1 + 3 }}{{ b }} + {% do c = d = "a" %}{{ c }}{{ d }} + {% do a = (b = 4) + 5 %}{{ a }}{{ b }} + INPUTS + end + + let(:outputs) do + <<~OUTPUTS + 4 + 44 + aa + 94 + OUTPUTS + end + + let(:locals) { {} } + end + end + + describe 'assignment in ternary' do + it_behaves_like 'render_and_assert' do + let(:input) { '{% do (c = 4) ? 0 : -1 %}{{ c }}' } + let(:output) { '4' } + let(:locals) { {} } + end + end + + describe 'assignment with named arguments' do + it_behaves_like 'render_and_assert' do + let(:input) { '{{ items|join(glue=", ") }}' } + let(:output) { 'a, b, c' } + let(:locals) { { items: %w[a b c] } } + end + end + + context 'when assigning to a non-variable' do + it_behaves_like 'render_and_raise' do + let(:template) { '{% do 5 = 3 %}' } + let(:error) { Twig::Error::Syntax } + let(:message) { /Cannot assign to/ } + end + end +end diff --git a/spec/lib/twig/node/expression/empty_slot_spec.rb b/spec/lib/twig/node/expression/empty_slot_spec.rb new file mode 100644 index 00000000..ba1ddf44 --- /dev/null +++ b/spec/lib/twig/node/expression/empty_slot_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'integration_shared_examples' + +RSpec.describe Twig::Node::Expression::EmptySlot do + context 'when used outside of destructuring' do + it_behaves_like 'render_and_raise' do + let(:template) { '{{ [, 1] }}' } + let(:error) { Twig::Error::Syntax } + let(:message) { /Empty array elements are only allowed in destructuring assignments/ } + end + end +end diff --git a/spec/lib/twig/runtime/escaper_spec.rb b/spec/lib/twig/runtime/escaper_spec.rb index dd0f3d19..15b5dd72 100644 --- a/spec/lib/twig/runtime/escaper_spec.rb +++ b/spec/lib/twig/runtime/escaper_spec.rb @@ -17,6 +17,44 @@ expect(escaper.escape('onclick:alert(1)', :html_attr)).to eq('onclick:alert(1)') end + describe ':html_attr_relaxed strategy' do + it 'preserves colons, at-signs, and brackets' do + expect(escaper.escape('v:bind@click[0]', :html_attr_relaxed)).to eq('v:bind@click[0]') + end + + it 'still escapes equals, quotes, and angle brackets' do + expect(escaper.escape('v:bind@click="foo"', :html_attr_relaxed)).to eq('v:bind@click="foo"') + end + + it 'escapes ampersands' do + expect(escaper.escape('a&b', :html_attr_relaxed)).to eq('a&b') + end + + it 'escapes spaces and parentheses' do + expect(escaper.escape('on click(x)', :html_attr_relaxed)).to eq('on click(x)') + end + + it 'preserves alphanumeric and basic safe chars' do + expect(escaper.escape('hello-world_123,test.ok', :html_attr_relaxed)).to eq('hello-world_123,test.ok') + end + + it 'escapes multi-byte characters' do + result = escaper.escape("\u00E9", :html_attr_relaxed) + expect(result).to eq('é') + end + + it 'replaces undefined HTML characters with replacement character' do + expect(escaper.escape("\x01", :html_attr_relaxed)).to eq('�') + end + + it 'raises on invalid UTF-8' do + expect { escaper.escape("Hello \x80 world!", :html_attr_relaxed) }.to raise_error( + Twig::Error::Runtime, + /not a valid UTF-8 string/ + ) + end + end + it 'escapes css' do expect(escaper.escape('div > a { color: blue; }', :css)).to eq( 'div\20 \3E \20 a\20 \7B \20 color\3A \20 blue\3B \20 \7D ' diff --git a/test/fixtures/expressions/set.test.rb b/test/fixtures/expressions/set.test.rb new file mode 100644 index 00000000..08ac35d4 --- /dev/null +++ b/test/fixtures/expressions/set.test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class TwigTestUserObj + def name + 'Fabien' + end +end + +class Data + def self.examples + [ + { + data: { + 'user' => { 'name' => 'Fabien', 'email' => 'fabien@example.com' }, + 'user_map' => { 'first_name' => 'Fabien', 'last_name' => 'Potencier' }, + 'user_obj' => TwigTestUserObj.new, + 'null_obj' => nil, + }, + config: {}, + }, + ] + end +end diff --git a/test/fixtures/expressions/spread_ternary_precedence.test.rb b/test/fixtures/expressions/spread_ternary_precedence.test.rb new file mode 100644 index 00000000..51ee66ac --- /dev/null +++ b/test/fixtures/expressions/spread_ternary_precedence.test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Data + def self.examples + [ + { + data: {}, + config: {}, + }, + ] + end +end diff --git a/test/fixtures/filters/escape_html_attr_relaxed.test.rb b/test/fixtures/filters/escape_html_attr_relaxed.test.rb new file mode 100644 index 00000000..51ee66ac --- /dev/null +++ b/test/fixtures/filters/escape_html_attr_relaxed.test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Data + def self.examples + [ + { + data: {}, + config: {}, + }, + ] + end +end