liquid2 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/.rubocop.yml +46 -0
  4. data/.ruby-version +1 -0
  5. data/.vscode/settings.json +32 -0
  6. data/CHANGELOG.md +5 -0
  7. data/LICENSE.txt +21 -0
  8. data/LICENSE_SHOPIFY.txt +20 -0
  9. data/README.md +219 -0
  10. data/Rakefile +23 -0
  11. data/Steepfile +26 -0
  12. data/lib/liquid2/context.rb +297 -0
  13. data/lib/liquid2/environment.rb +287 -0
  14. data/lib/liquid2/errors.rb +79 -0
  15. data/lib/liquid2/expression.rb +20 -0
  16. data/lib/liquid2/expressions/arguments.rb +25 -0
  17. data/lib/liquid2/expressions/array.rb +20 -0
  18. data/lib/liquid2/expressions/blank.rb +41 -0
  19. data/lib/liquid2/expressions/boolean.rb +20 -0
  20. data/lib/liquid2/expressions/filtered.rb +136 -0
  21. data/lib/liquid2/expressions/identifier.rb +43 -0
  22. data/lib/liquid2/expressions/lambda.rb +53 -0
  23. data/lib/liquid2/expressions/logical.rb +71 -0
  24. data/lib/liquid2/expressions/loop.rb +79 -0
  25. data/lib/liquid2/expressions/path.rb +33 -0
  26. data/lib/liquid2/expressions/range.rb +28 -0
  27. data/lib/liquid2/expressions/relational.rb +119 -0
  28. data/lib/liquid2/expressions/template_string.rb +20 -0
  29. data/lib/liquid2/filter.rb +95 -0
  30. data/lib/liquid2/filters/array.rb +202 -0
  31. data/lib/liquid2/filters/date.rb +20 -0
  32. data/lib/liquid2/filters/default.rb +16 -0
  33. data/lib/liquid2/filters/json.rb +15 -0
  34. data/lib/liquid2/filters/math.rb +87 -0
  35. data/lib/liquid2/filters/size.rb +11 -0
  36. data/lib/liquid2/filters/slice.rb +17 -0
  37. data/lib/liquid2/filters/sort.rb +96 -0
  38. data/lib/liquid2/filters/string.rb +204 -0
  39. data/lib/liquid2/loader.rb +59 -0
  40. data/lib/liquid2/loaders/file_system_loader.rb +76 -0
  41. data/lib/liquid2/loaders/mixins.rb +52 -0
  42. data/lib/liquid2/node.rb +113 -0
  43. data/lib/liquid2/nodes/comment.rb +18 -0
  44. data/lib/liquid2/nodes/output.rb +24 -0
  45. data/lib/liquid2/nodes/tags/assign.rb +35 -0
  46. data/lib/liquid2/nodes/tags/block_comment.rb +26 -0
  47. data/lib/liquid2/nodes/tags/capture.rb +40 -0
  48. data/lib/liquid2/nodes/tags/case.rb +111 -0
  49. data/lib/liquid2/nodes/tags/cycle.rb +63 -0
  50. data/lib/liquid2/nodes/tags/decrement.rb +29 -0
  51. data/lib/liquid2/nodes/tags/doc.rb +24 -0
  52. data/lib/liquid2/nodes/tags/echo.rb +31 -0
  53. data/lib/liquid2/nodes/tags/extends.rb +3 -0
  54. data/lib/liquid2/nodes/tags/for.rb +155 -0
  55. data/lib/liquid2/nodes/tags/if.rb +84 -0
  56. data/lib/liquid2/nodes/tags/include.rb +123 -0
  57. data/lib/liquid2/nodes/tags/increment.rb +29 -0
  58. data/lib/liquid2/nodes/tags/inline_comment.rb +28 -0
  59. data/lib/liquid2/nodes/tags/liquid.rb +29 -0
  60. data/lib/liquid2/nodes/tags/macro.rb +3 -0
  61. data/lib/liquid2/nodes/tags/raw.rb +30 -0
  62. data/lib/liquid2/nodes/tags/render.rb +137 -0
  63. data/lib/liquid2/nodes/tags/tablerow.rb +143 -0
  64. data/lib/liquid2/nodes/tags/translate.rb +3 -0
  65. data/lib/liquid2/nodes/tags/unless.rb +23 -0
  66. data/lib/liquid2/nodes/tags/with.rb +3 -0
  67. data/lib/liquid2/parser.rb +917 -0
  68. data/lib/liquid2/scanner.rb +595 -0
  69. data/lib/liquid2/static_analysis.rb +301 -0
  70. data/lib/liquid2/tag.rb +22 -0
  71. data/lib/liquid2/template.rb +182 -0
  72. data/lib/liquid2/undefined.rb +131 -0
  73. data/lib/liquid2/utils/cache.rb +80 -0
  74. data/lib/liquid2/utils/chain_hash.rb +40 -0
  75. data/lib/liquid2/utils/unescape.rb +119 -0
  76. data/lib/liquid2/version.rb +5 -0
  77. data/lib/liquid2.rb +90 -0
  78. data/performance/benchmark.rb +73 -0
  79. data/performance/memory_profile.rb +62 -0
  80. data/performance/profile.rb +71 -0
  81. data/sig/liquid2.rbs +2348 -0
  82. data.tar.gz.sig +0 -0
  83. metadata +164 -0
  84. metadata.gz.sig +0 -0
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liquid2
4
+ # Combine multiple hashes for sequential lookup.
5
+ class ReadOnlyChainHash
6
+ # @param hashes
7
+ def initialize(*hashes)
8
+ @hashes = hashes.to_a
9
+ end
10
+
11
+ def [](key)
12
+ index = @hashes.length - 1
13
+ while index >= 0
14
+ h = @hashes[index]
15
+ index -= 1
16
+ return h[key] if h.key?(key)
17
+ end
18
+ end
19
+
20
+ def key?(key)
21
+ !@hashes.rindex { |h| h.key?(key) }.nil?
22
+ end
23
+
24
+ def fetch(key, default = :undefined)
25
+ index = @hashes.length - 1
26
+ while index >= 0
27
+ h = @hashes[index]
28
+ index -= 1
29
+ return h[key] if h.key?(key)
30
+ end
31
+
32
+ default
33
+ end
34
+
35
+ def size = @hashes.length
36
+ def push(hash) = @hashes << hash
37
+ alias << push
38
+ def pop = @hashes.pop
39
+ end
40
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liquid2 # :nodoc:
4
+ # Replace escape sequences with their equivalent Unicode code point.
5
+ # This is a bit like Ruby's String#undump, but assumes surrounding quotes have been removed
6
+ # and follows JSON escaping semantics.
7
+ # @param value [String]
8
+ # @param quote [String] one of '"' or "'".
9
+ # @param token [Token]
10
+ # @return [String] A new string without escape sequences.
11
+ def self.unescape_string(value, quote, token)
12
+ unescaped = String.new(encoding: "UTF-8")
13
+ index = 0
14
+ length = value.length
15
+
16
+ while index < length
17
+ ch = value[index] || raise
18
+ if ch == "\\"
19
+ index += 1
20
+ case value[index]
21
+ when quote
22
+ unescaped << quote
23
+ when "\\"
24
+ unescaped << "\\"
25
+ when "/"
26
+ unescaped << "/"
27
+ when "b"
28
+ unescaped << "\x08"
29
+ when "f"
30
+ unescaped << "\x0C"
31
+ when "n"
32
+ unescaped << "\n"
33
+ when "r"
34
+ unescaped << "\r"
35
+ when "t"
36
+ unescaped << "\t"
37
+ when "u"
38
+ code_point, index = Liquid2.decode_hex_char(value, index, token)
39
+ unescaped << Liquid2.code_point_to_string(code_point, token)
40
+ when "$"
41
+ unescaped << "$"
42
+ else
43
+ raise LiquidSyntaxError.new("unknown escape sequence", token)
44
+ end
45
+ else
46
+ # raise LiquidSyntaxError.new("invalid character #{ch.inspect}", token) if ch.ord <= 0x1F
47
+
48
+ unescaped << ch
49
+ end
50
+
51
+ index += 1
52
+
53
+ end
54
+
55
+ unescaped
56
+ end
57
+
58
+ def self.decode_hex_char(value, index, token)
59
+ length = value.length
60
+
61
+ raise LiquidSyntaxError.new("incomplete escape sequence", token) if index + 4 >= length
62
+
63
+ index += 1 # move past 'u'
64
+ code_point = parse_hex_digits(value[index, 4] || raise, token)
65
+
66
+ raise LiquidSyntaxError.new("unexpected low surrogate", token) if low_surrogate?(code_point)
67
+
68
+ return [code_point, index + 3] unless high_surrogate?(code_point)
69
+
70
+ unless index + 9 < length && value[index + 4] == "\\" && value[index + 5] == "u"
71
+ raise LiquidSyntaxError.new("incomplete escape sequence", token)
72
+ end
73
+
74
+ low_surrogate = parse_hex_digits(value[index + 6, 10] || raise, token)
75
+
76
+ unless low_surrogate?(low_surrogate)
77
+ raise LiquidSyntaxError.new("unexpected low surrogate",
78
+ token)
79
+ end
80
+
81
+ code_point = 0x10000 + (
82
+ ((code_point & 0x03FF) << 10) | (low_surrogate & 0x03FF)
83
+ )
84
+
85
+ [code_point, index + 9]
86
+ end
87
+
88
+ def self.parse_hex_digits(digits, token)
89
+ code_point = 0
90
+ digits.each_byte do |b|
91
+ code_point <<= 4
92
+ case b
93
+ when 48..57
94
+ code_point |= b - 48
95
+ when 65..70
96
+ code_point |= b - 65 + 10
97
+ when 97..102
98
+ code_point |= b - 97 + 10
99
+ else
100
+ raise LiquidSyntaxError.new("invalid escape sequence", token)
101
+ end
102
+ end
103
+ code_point
104
+ end
105
+
106
+ def self.high_surrogate?(code_point)
107
+ code_point.between?(0xD800, 0xDBFF)
108
+ end
109
+
110
+ def self.low_surrogate?(code_point)
111
+ code_point.between?(0xDC00, 0xDFFF)
112
+ end
113
+
114
+ def self.code_point_to_string(code_point, token)
115
+ raise LiquidSyntaxError.new("invalid character", token) if code_point <= 8
116
+
117
+ code_point.chr(Encoding::UTF_8)
118
+ end
119
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liquid2
4
+ VERSION = "0.1.0"
5
+ end
data/lib/liquid2.rb ADDED
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "liquid2/environment"
5
+ require_relative "liquid2/context"
6
+ require_relative "liquid2/filter"
7
+ require_relative "liquid2/scanner"
8
+ require_relative "liquid2/loader"
9
+ require_relative "liquid2/parser"
10
+ require_relative "liquid2/version"
11
+ require_relative "liquid2/undefined"
12
+ require_relative "liquid2/utils/chain_hash"
13
+ require_relative "liquid2/utils/unescape"
14
+ require_relative "liquid2/static_analysis"
15
+ require_relative "liquid2/loaders/file_system_loader"
16
+
17
+ # Liquid template engine.
18
+ module Liquid2
19
+ DEFAULT_ENVIRONMENT = Environment.new
20
+
21
+ # Parse _source_ text as a template using the default Liquid environment.
22
+ # @param source [String]
23
+ # @param data [?Hash[String, untyped]?]
24
+ # @return [Template]
25
+ def self.parse(source, globals: nil)
26
+ DEFAULT_ENVIRONMENT.parse(source, globals: globals)
27
+ end
28
+
29
+ # Parse and render template _source_ with _data_ as template variables and
30
+ # the default Liquid environment.
31
+ # @param source [String]
32
+ # @param data [?Hash[String, untyped]?]
33
+ # @return [String]
34
+ def self.render(source, data = nil)
35
+ DEFAULT_ENVIRONMENT.render(source, data)
36
+ end
37
+
38
+ # Stringify an object. Use this anywhere a string is expected, like in a filter.
39
+ def self.to_liquid_string(obj)
40
+ case obj
41
+ when Hash, Array
42
+ JSON.generate(obj)
43
+ else
44
+ obj.to_s
45
+ end
46
+ end
47
+
48
+ # Stringify an object for output. Use this when writing directly to an output buffer.
49
+ def self.to_output_string(obj)
50
+ case obj
51
+ when Array
52
+ # Concatenate string representations of array elements.
53
+ obj.map do |item|
54
+ Liquid2.to_s(item)
55
+ end.join
56
+ when BigDecimal
57
+ # TODO: test capture
58
+ # TODO: are there any scenarios where we need to cast to_f before output?
59
+ obj.to_f.to_s
60
+ else
61
+ Liquid2.to_s(obj)
62
+ end
63
+ end
64
+
65
+ def self.to_liquid_int(obj, default: 0)
66
+ Float(obj).to_i
67
+ rescue ArgumentError, TypeError
68
+ default
69
+ end
70
+
71
+ # Return `true` if _obj_ is Liquid truthy.
72
+ # @param context [RenderContext]
73
+ # @param obj [Object]
74
+ # @return [bool]
75
+ def self.truthy?(context, obj)
76
+ obj = obj.to_liquid(context) if obj.respond_to?(:to_liquid)
77
+ !!obj
78
+ end
79
+
80
+ # Return `true` if _obj_ is undefined.
81
+ def self.undefined?(obj)
82
+ obj.is_a?(Undefined)
83
+ end
84
+
85
+ class << self
86
+ alias to_s to_liquid_string
87
+ alias to_output_s to_output_string
88
+ alias to_i to_liquid_int
89
+ end
90
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark/ips"
4
+ require "json"
5
+ require "optparse"
6
+ require "pathname"
7
+ require "liquid2"
8
+
9
+ # A benchmark fixture
10
+ class Fixture
11
+ attr_reader :templates, :data, :name
12
+
13
+ # @param path [Pathname]
14
+ def initialize(path)
15
+ @root = path
16
+ @name = @root.basename.to_s
17
+ # rubocop:disable Style/StringConcatenation
18
+ @data = JSON.parse((@root + "data.json").read)
19
+ @templates = (@root + "templates").glob("*liquid").to_h { |p| [p.basename.to_s, p.read] }
20
+ # rubocop:enable Style/StringConcatenation
21
+ end
22
+
23
+ def env
24
+ Liquid2::Environment.new(loader: Liquid2::HashLoader.new(@templates), globals: @data)
25
+ end
26
+ end
27
+
28
+ options = {
29
+ fixture: "002"
30
+ }
31
+
32
+ OptionParser.new do |parser|
33
+ parser.banner = <<~BANNER
34
+ Run one of the benchmarks in ./tests/cts/benchmark_fixtures.
35
+ Example: ruby benchmark.rb -f 002
36
+ BANNER
37
+
38
+ parser.on("-f FIXTURE", "--fixture FIXTURE",
39
+ "The name of the benchmark fixture to run. Defaults to '002'.") do |value|
40
+ options[:fixture] = value
41
+ end
42
+
43
+ parser.parse!
44
+ end
45
+
46
+ fixture = Fixture.new(Pathname.new("test/golden_liquid/benchmark_fixtures") + options[:fixture])
47
+ env = fixture.env
48
+ source = fixture.templates["index.liquid"]
49
+ template = env.get_template("index.liquid")
50
+
51
+ # scanner = StringScanner.new("")
52
+
53
+ Benchmark.ips do |x|
54
+ # Configure the number of seconds used during
55
+ # the warmup phase (default 2) and calculation phase (default 5)
56
+ x.config(warmup: 2, time: 5)
57
+
58
+ # x.report("tokenize (#{fixture.name}):") do
59
+ # Liquid2::Scanner.tokenize(source, scanner)
60
+ # end
61
+
62
+ x.report("parse (#{fixture.name}):") do
63
+ env.parse(source)
64
+ end
65
+
66
+ x.report("render (#{fixture.name}):") do
67
+ template.render
68
+ end
69
+
70
+ x.report("both (#{fixture.name}):") do
71
+ env.parse(source).render
72
+ end
73
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "memory_profiler"
4
+ require "json"
5
+ require "optparse"
6
+ require "pathname"
7
+ require "liquid2"
8
+
9
+ # A benchmark fixture
10
+ class Fixture
11
+ attr_reader :templates, :data
12
+
13
+ # @param path [Pathname]
14
+ def initialize(path)
15
+ @root = path
16
+ @name = @root.basename.to_s
17
+ # rubocop:disable Style/StringConcatenation
18
+ @data = JSON.parse((@root + "data.json").read)
19
+ @templates = (@root + "templates").glob("*liquid").to_h { |p| [p.basename.to_s, p.read] }
20
+ # rubocop:enable Style/StringConcatenation
21
+ end
22
+
23
+ def env
24
+ Liquid2::Environment.new(loader: Liquid2::HashLoader.new(@templates), globals: @data)
25
+ end
26
+ end
27
+
28
+ # TODO: add an option to profile parsing or rendering of Liquid templates
29
+
30
+ options = {
31
+ fixture: "002"
32
+ }
33
+
34
+ OptionParser.new do |parser|
35
+ parser.banner = <<~BANNER
36
+ Run one of the benchmarks in ./tests/cts/benchmark_fixtures.
37
+ Example: ruby benchmark.rb -f 002
38
+ BANNER
39
+
40
+ parser.on("-f FIXTURE", "--fixture FIXTURE",
41
+ "The name of the benchmark fixture to run. Defaults to '002'.") do |value|
42
+ options[:fixture] = value
43
+ end
44
+
45
+ parser.parse!
46
+ end
47
+
48
+ fixture = Fixture.new(Pathname.new("test/golden_liquid/benchmark_fixtures") + options[:fixture])
49
+ env = fixture.env
50
+ # source = fixture.templates["index.liquid"]
51
+ template = env.get_template("index.liquid")
52
+
53
+ n = 1000
54
+
55
+ report = MemoryProfiler.report do
56
+ n.times do
57
+ template.render
58
+ # Liquid2.tokenize(source)
59
+ end
60
+ end
61
+
62
+ report.pretty_print
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stackprof"
4
+ require "optparse"
5
+ require "pathname"
6
+ require "json"
7
+ require "liquid2"
8
+
9
+ # A benchmark fixture
10
+ class Fixture
11
+ attr_reader :templates, :data
12
+
13
+ # @param path [Pathname]
14
+ def initialize(path)
15
+ @root = path
16
+ @name = @root.basename.to_s
17
+ # rubocop:disable Style/StringConcatenation
18
+ @data = JSON.parse((@root + "data.json").read)
19
+ @templates = (@root + "templates").glob("*liquid").to_h { |p| [p.basename.to_s, p.read] }
20
+ # rubocop:enable Style/StringConcatenation
21
+ end
22
+
23
+ def env
24
+ Liquid2::Environment.new(loader: Liquid2::HashLoader.new(@templates), globals: @data)
25
+ end
26
+ end
27
+
28
+ options = {
29
+ fixture: "002"
30
+ }
31
+
32
+ OptionParser.new do |parser|
33
+ parser.banner = <<~BANNER
34
+ Run one of the benchmarks in ./tests/cts/benchmark_fixtures.
35
+ Example: ruby benchmark.rb -f 002
36
+ BANNER
37
+
38
+ parser.on("-f FIXTURE", "--fixture FIXTURE",
39
+ "The name of the benchmark fixture to run. Defaults to '002'.") do |value|
40
+ options[:fixture] = value
41
+ end
42
+
43
+ parser.parse!
44
+ end
45
+
46
+ fixture = Fixture.new(Pathname.new("test/golden_liquid/benchmark_fixtures") + options[:fixture])
47
+ env = fixture.env
48
+ source = fixture.templates["index.liquid"]
49
+ template = env.get_template("index.liquid")
50
+
51
+ n = 1000
52
+
53
+ scanner = StringScanner.new("")
54
+
55
+ StackProf.run(mode: :cpu, raw: true, out: ".stackprof-cpu-scan.dump") do
56
+ n.times do
57
+ Liquid2::Scanner.tokenize(source, scanner)
58
+ end
59
+ end
60
+
61
+ StackProf.run(mode: :cpu, raw: true, out: ".stackprof-cpu-parse.dump") do
62
+ n.times do
63
+ env.parse(source)
64
+ end
65
+ end
66
+
67
+ StackProf.run(mode: :cpu, raw: true, out: ".stackprof-cpu-render.dump") do
68
+ n.times do
69
+ template.render
70
+ end
71
+ end