├── .editorconfig ├── .github └── workflows │ └── development.yml ├── .gitignore ├── .rspec ├── README.md ├── bake └── trenni │ ├── entities.rb │ └── parsers.rb ├── benchmark ├── call_vs_yield.rb ├── interpolation_vs_concat.rb └── io_vs_string.rb ├── entities.json ├── ext └── trenni │ ├── escape.c │ ├── escape.h │ ├── extconf.rb │ ├── markup.c │ ├── markup.h │ ├── markup.rl │ ├── query.c │ ├── query.h │ ├── query.rl │ ├── tag.c │ ├── tag.h │ ├── template.c │ ├── template.h │ ├── template.rl │ ├── trenni.c │ └── trenni.h ├── gems.rb ├── lib ├── trenni.rb └── trenni │ ├── buffer.rb │ ├── builder.rb │ ├── entities.rb │ ├── entities.trenni │ ├── error.rb │ ├── fallback │ ├── markup.rb │ ├── markup.rl │ ├── query.rb │ ├── query.rl │ ├── template.rb │ └── template.rl │ ├── markup.rb │ ├── native.rb │ ├── parse_delegate.rb │ ├── parsers.rb │ ├── query.rb │ ├── reference.rb │ ├── strings.rb │ ├── tag.rb │ ├── template.rb │ ├── uri.rb │ └── version.rb ├── parsers └── trenni │ ├── entities.rl │ ├── markup.rl │ ├── query.rl │ └── template.rl ├── spec ├── spec_helper.rb └── trenni │ ├── builder_spec.rb │ ├── corpus │ ├── large.rb │ └── large.xhtml │ ├── markup_parser_spec.rb │ ├── markup_performance_spec.rb │ ├── markup_spec.rb │ ├── parsers_performance_spec.rb │ ├── query_spec.rb │ ├── reference_spec.rb │ ├── strings_spec.rb │ ├── tag_spec.rb │ ├── template_error_spec.rb │ ├── template_performance_spec.rb │ ├── template_spec.rb │ ├── template_spec │ ├── basic.trenni │ ├── buffer.trenni │ ├── builder.trenni │ ├── capture.trenni │ ├── error.trenni │ ├── escaped.trenni │ ├── interpolations.trenni │ ├── large.erb │ ├── large.trenni │ ├── lines.trenni │ └── nested.trenni │ └── uri_spec.rb └── trenni.gemspec /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /.github/workflows/development.yml: -------------------------------------------------------------------------------- 1 | name: Development 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: ${{matrix.ruby}} on ${{matrix.os}} 8 | runs-on: ${{matrix.os}}-latest 9 | continue-on-error: ${{matrix.experimental}} 10 | 11 | strategy: 12 | matrix: 13 | os: 14 | - ubuntu 15 | - macos 16 | 17 | ruby: 18 | - "2.6" 19 | - "2.7" 20 | - "3.0" 21 | 22 | experimental: [false] 23 | env: ["", "TRENNI_PREFER_FALLBACK=y"] 24 | 25 | include: 26 | - os: ubuntu 27 | ruby: truffleruby 28 | experimental: true 29 | - os: ubuntu 30 | ruby: jruby 31 | experimental: true 32 | - os: ubuntu 33 | ruby: head 34 | experimental: true 35 | 36 | steps: 37 | - uses: actions/checkout@v2 38 | - uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: ${{matrix.ruby}} 41 | bundler-cache: true 42 | 43 | - name: Run tests 44 | timeout-minutes: 5 45 | run: | 46 | ${{matrix.env}} bundle exec bake trenni:parsers:generate 47 | ${{matrix.env}} bundle exec rspec 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tags 2 | 3 | /.bundle/ 4 | /.yardoc 5 | /gems.locked 6 | /_yardoc/ 7 | /coverage/ 8 | /doc/ 9 | /pkg/ 10 | /spec/reports/ 11 | tmp/ 12 | 13 | .rspec_status 14 | .covered.db 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | --backtrace 4 | --warnings 5 | --require spec_helper -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trenni 2 | 3 | > [!NOTE] 4 | > Trenni and all related gems are replaced by the [XRB](https://github.com/socketry/xrb). 5 | 6 | Trenni is a templating system built loosely on top of XHTML markup. It uses efficient native parsers where possible and compiles templates into efficient Ruby. It also includes a markup builder to assist with the generation of pleasantly formatted markup which is compatible with the included parsers. 7 | 8 | [](https://github.com/ioquatix/trenni/actions?workflow=Development) 9 | 10 | ## Motivation 11 | 12 | Trenni was designed for [Utopia](https://github.com/ioquatix/utopia). When I originally looked at template engines, I was surprised by the level of complexity and the effort involved in processing a template to produce useful output. In particular, many template engines generate an AST and walk over it to generate output (e.g. ERB, at least at the time I last checked). This is exceedingly slow in Ruby. 13 | 14 | At the time (around 2008?) I was playing around with [ramaze](https://github.com/Ramaze/ramaze) and found a template engine I really liked the design of, called [ezamar](https://github.com/manveru/ezamar). The template compilation process actually generates Ruby code which can then be compiled and executed efficiently. Another engine, by the same author, [nagoro](https://github.com/manveru/nagoro), also provided some inspiration. 15 | 16 | More recently I was doing some investigation regarding using `eval` for executing the code. The problem is that it's [not possible to replace the binding](http://stackoverflow.com/questions/27391909/replace-evalcode-string-binding-with-lambda/27392437) of a `Proc` once it's created, so template engines that evaluate code in a given binding cannot use a compiled proc, they must parse the code every time. By using a `Proc` we can generate a Ruby function which *can* be compiled to a faster representation by the VM. 17 | 18 | In addition, I wanted a simple markup parser and builder for HTML style markup. These are used heavily by Utopia for implementing it's tag based evaluation. `Trenni::Builder` is a simple and efficient way to generate markup, it's not particularly notable, except that it doesn't use `method_missing` to [implement normal behaviour](https://github.com/sparklemotion/nokogiri/blob/b6679e928924529b56dcc0f3164224c040d14555/lib/nokogiri/xml/builder.rb#L355) which is [sort of slow](http://franck.verrot.fr/blog/2015/07/12/benchmarking-ruby-method-missing-and-define-method/). 19 | 20 | The 2nd release of Trenni in 2016 saw an overhaul of the internal parsers. I used [Ragel](http://www.colm.net/open-source/ragel/) to implement efficient event-based markup and template parsers, which can be compiled to both C and Ruby. This provides a native code path where possible giving speed-ups between 10x - 20x. In addition, the formal grammar is more robust. 21 | 22 | The 3rd release of Trenni in 2017 was primarily focused on performance, by moving more of the critical parsing, escaping and tag generation functions to C. In practical usage, this gave about a 40-50% improvement in performance overall. 23 | 24 | ## Is it fast? 25 | 26 | It's faster than Nokogiri for parsing markup: 27 | 28 | Trenni::Native 29 | Warming up -------------------------------------- 30 | Large (Trenni) 71.000 i/100ms 31 | Large (Nokogiri) 28.000 i/100ms 32 | Calculating ------------------------------------- 33 | Large (Trenni) 662.050 (± 3.9%) i/s - 3.337k in 5.048115s 34 | Large (Nokogiri) 266.878 (±10.9%) i/s - 1.316k in 5.008464s 35 | 36 | Comparison: 37 | Large (Trenni): 662.1 i/s 38 | Large (Nokogiri): 266.9 i/s - 2.48x slower 39 | 40 | It's significantly faster than ERB: 41 | 42 | Trenni::Template 43 | Warming up -------------------------------------- 44 | Trenni (object) 75.667k i/100ms 45 | ERB (binding) 6.940k i/100ms 46 | Calculating ------------------------------------- 47 | Trenni (object) 1.095M (± 7.9%) i/s - 5.448M in 5.007244s 48 | ERB (binding) 69.381k (± 7.1%) i/s - 347.000k in 5.027333s 49 | 50 | Comparison: 51 | Trenni (object): 1094979.9 i/s 52 | ERB (binding): 69381.1 i/s - 15.78x slower 53 | 54 | ## Installation 55 | 56 | Add this line to your application's Gemfile: 57 | 58 | gem 'trenni' 59 | 60 | And then execute: 61 | 62 | $ bundle 63 | 64 | Or install it yourself as: 65 | 66 | $ gem install trenni 67 | 68 | ## Usage 69 | 70 | ### Markup Parser 71 | 72 | The markup parser parses a loose super-set of HTML in a way that's useful for content processing, similar to an XSLT processor. It's designed to be faster and easier to use, and integrate directly into an output pipeline. 73 | 74 | To invoke the markup parser: 75 | 76 | ``` ruby 77 | require 'trenni' 78 | 79 | buffer = Trenni::Buffer(string) 80 | 81 | # Custom entities, or could use Trenni::Entities::HTML5 82 | entities = {'amp' => '&', 'lt' => '<', 'gt' => '>', 'quot' => '"'} 83 | 84 | # Modify this class to accumulate events or pass them on somewhere else. 85 | class Delegate 86 | # Called when encountering an open tag: `<` name 87 | def open_tag_begin(name, offset) 88 | end 89 | 90 | # Called when encountering an attribute after open_tag_begin 91 | def attribute(key, value) 92 | end 93 | 94 | # Called when encountering the end of the opening tag. 95 | def open_tag_end(self_closing) 96 | end 97 | 98 | # Called when encountering the closing tag: '' name '>' 99 | def close_tag(name, offset) 100 | end 101 | 102 | # Called with the full doctype: '' 103 | def doctype(string) 104 | end 105 | 106 | # Called with the full comment: '' 107 | def comment(string) 108 | end 109 | 110 | # Called with the parsed instruction: '' identifier space+ body '?>' 111 | def instruction(string) 112 | end 113 | 114 | # Called with a cdata block: '' 115 | def cdata(string) 116 | end 117 | 118 | # Called with any arbitrary pcdata text (e.g. between tags). 119 | def text(string) 120 | end 121 | end 122 | 123 | # Do the actual work: 124 | Trenni::Parsers.parse_markup(buffer, Delegate.new, entities) 125 | ``` 126 | 127 | ### Templates 128 | 129 | Trenni templates work essentially the same way as all other templating systems: 130 | 131 | ``` ruby 132 | buffer = Trenni::Buffer('#{item}') 133 | template = Trenni::Template.new(buffer) 134 | 135 | items = 1..4 136 | 137 | template.to_string(items) # => "1234" 138 | ``` 139 | 140 | The code above demonstrate the only two constructs, `` and `#{output}`. 141 | 142 | Trenni doesn't support using `binding` for evaluation, as this is a slow code path. It uses `instance_exec` 143 | 144 | ### Builder 145 | 146 | Trenni can help construct XML/HTML using a simple DSL: 147 | 148 | ``` ruby 149 | Trenni::Builder.fragment do |builder| 150 | builder.inline 'p' do 151 | builder.tag 'strong' do 152 | builder.text 'Hello' 153 | end 154 | builder.text ' World' 155 | end 156 | end.to_s 157 | # => "
Hello World
" 158 | ``` 159 | 160 | ### Testing 161 | 162 | To test the Ruby parsers: 163 | 164 | rake generate_fallback_parsers && TRENNI_PREFER_FALLBACK=y rspec 165 | 166 | To test the native C parsers: 167 | 168 | rake generate_native_parsers && rake compile && rspec 169 | 170 | ### Benchmarks 171 | 172 | Trenni has a pure Ruby implemenation, with performance critical operations implemented natively. All critical code paths have benchmark specs. 173 | 174 | #### Parser Performance 175 | 176 | You can evaluate and compare template performance with ERB: 177 | 178 | rspec spec/trenni/parsers_performance_spec.rb 179 | 180 | Trenni::Native 181 | #parse_markup 182 | Warming up -------------------------------------- 183 | Large (Trenni) 64.000 i/100ms 184 | Large (Nokogiri) 30.000 i/100ms 185 | Calculating ------------------------------------- 186 | Large (Trenni) 637.720 (± 6.4%) i/s - 3.200k in 5.038187s 187 | Large (Nokogiri) 294.762 (± 5.8%) i/s - 1.470k in 5.004284s 188 | 189 | Comparison: 190 | Large (Trenni): 637.7 i/s 191 | Large (Nokogiri): 294.8 i/s - 2.16x slower 192 | 193 | should be fast to parse large documents 194 | #parse_template 195 | Warming up -------------------------------------- 196 | Large (Trenni) 7.791k i/100ms 197 | Large (ERB) 488.000 i/100ms 198 | Calculating ------------------------------------- 199 | Large (Trenni) 87.889k (± 9.5%) i/s - 436.296k in 5.024283s 200 | Large (ERB) 4.844k (± 5.6%) i/s - 24.400k in 5.053247s 201 | 202 | Comparison: 203 | Large (Trenni): 87889.4 i/s 204 | Large (ERB): 4844.5 i/s - 18.14x slower 205 | 206 | should have better performance using instance 207 | 208 | Finished in 28.2 seconds (files took 0.14204 seconds to load) 209 | 2 examples, 0 failures 210 | 211 | To run this with the pure ruby implementation, use `TRENNI_PREFER_FALLBACK=y rspec spec/trenni/parsers_performance_spec.rb`. 212 | 213 | #### Markup String Performance 214 | 215 | Markup safe strings require escaping characters. Doing this natively makes sense, and in MRI, `CGI.escape_html` is implemented in C. Strings that include characters that need to be escaped are a bit slower because a new string must be allocated and modified. So, we test these two cases. 216 | 217 | rspec spec/trenni/markup_performance_spec.rb 218 | 219 | Trenni::Markup 220 | Warming up -------------------------------------- 221 | General String 179.396k i/100ms 222 | Code String 85.050k i/100ms 223 | Calculating ------------------------------------- 224 | General String 4.773M (±10.0%) i/s - 23.680M in 5.027576s 225 | Code String 1.469M (± 5.7%) i/s - 7.399M in 5.052467s 226 | 227 | Comparison: 228 | General String: 4773201.3 i/s 229 | Code String: 1469345.5 i/s - 3.25x slower 230 | 231 | should be fast to parse large documents 232 | 233 | Finished in 14.11 seconds (files took 0.09696 seconds to load) 234 | 1 example, 0 failures 235 | 236 | #### Template Evaluation Performance 237 | 238 | Evaluating templates and generating output is critical to performance. You can compare Trenni with ERB. The primary factor affecting performance, is the number of interpolations, because each interpolation requires evaluation and concatenation. 239 | 240 | rspec spec/trenni/template_performance_spec.rb 241 | 242 | Trenni::Template 243 | Warming up -------------------------------------- 244 | Trenni 79.000 i/100ms 245 | Calculating ------------------------------------- 246 | Trenni 817.703 (± 7.7%) i/s - 4.108k in 5.071586s 247 | should be fast for lots of interpolations 248 | Warming up -------------------------------------- 249 | Trenni (object) 79.149k i/100ms 250 | ERB (binding) 5.416k i/100ms 251 | Calculating ------------------------------------- 252 | Trenni (object) 1.081M (± 3.7%) i/s - 5.461M in 5.059151s 253 | ERB (binding) 59.016k (± 4.7%) i/s - 297.880k in 5.058614s 254 | 255 | Comparison: 256 | Trenni (object): 1080909.2 i/s 257 | ERB (binding): 59016.3 i/s - 18.32x slower 258 | 259 | should be fast for basic templates 260 | Warming up -------------------------------------- 261 | Trenni 34.204k i/100ms 262 | Calculating ------------------------------------- 263 | Trenni 407.905k (± 9.0%) i/s - 2.018M in 5.001248s 264 | should be fast with capture 265 | 266 | Finished in 28.25 seconds (files took 0.09765 seconds to load) 267 | 3 examples, 0 failures 268 | 269 | ## See Also 270 | 271 | - [language-trenni](https://atom.io/packages/language-trenni) package for the [Atom text editor](https://atom.io). It provides syntax highlighting and integration when Trenni is used with the [utopia web framework](https://github.com/ioquatix/utopia). 272 | 273 | - [vim-trenni](https://github.com/huba/vim-trenni) package for Vim. 274 | 275 | - [Trenni Formatters](https://github.com/ioquatix/trenni-formatters) is a separate gem that uses `Trenni::Builder` to generate HTML forms easily. 276 | 277 | ## Contributing 278 | 279 | 1. Fork it 280 | 2. Create your feature branch (`git checkout -b my-new-feature`) 281 | 3. Commit your changes (`git commit -am 'Add some feature'`) 282 | 4. Push to the branch (`git push origin my-new-feature`) 283 | 5. Create new Pull Request 284 | 285 | ## License 286 | 287 | Released under the MIT license. 288 | 289 | Copyright, 2017, by [Samuel G. D. Williams](http://www.codeotaku.com/samuel-williams). 290 | 291 | Permission is hereby granted, free of charge, to any person obtaining a copy 292 | of this software and associated documentation files (the "Software"), to deal 293 | in the Software without restriction, including without limitation the rights 294 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 295 | copies of the Software, and to permit persons to whom the Software is 296 | furnished to do so, subject to the following conditions: 297 | 298 | The above copyright notice and this permission notice shall be included in 299 | all copies or substantial portions of the Software. 300 | 301 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 302 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 303 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 304 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 305 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 306 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 307 | THE SOFTWARE. 308 | -------------------------------------------------------------------------------- /bake/trenni/entities.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Fetch the HTML5 entities from w3.org and update the local cache. 4 | # @parameter force [Boolean] Whether to force regenerate the local cache. 5 | def fetch_entities(force: false) 6 | require 'json' 7 | require 'async' 8 | require 'async/http/internet' 9 | 10 | internet = Async::HTTP::Internet.new 11 | entites_json_path = self.entites_json_path 12 | 13 | if force || !File.exist?(entites_json_path) 14 | url = "https://www.w3.org/TR/html5/entities.json" 15 | 16 | Sync do 17 | File.write(entites_json_path, internet.get(url).read) 18 | end 19 | end 20 | 21 | return JSON.parse(File.read(entites_json_path)).delete_if{|string, _| !string.end_with? ';'} 22 | end 23 | 24 | # Consume the HTML5 entites and generate parsers to escape them. 25 | # @parameter wet [Boolean] Whether to write updated files. 26 | def update_entities(wet: false) 27 | require 'trenni/template' 28 | 29 | paths = { 30 | # 'ext/trenni/entities.rl' => 'ext/trenni/entities.trenni', 31 | 'lib/trenni/entities.rb' => 'lib/trenni/entities.trenni', 32 | } 33 | 34 | entities = self.fetch_entities 35 | 36 | paths.each do |output_path, template_path| 37 | template_path = File.expand_path(template_path, context.root) 38 | output_path = File.expand_path(output_path, context.root) 39 | 40 | template = Trenni::Template.load_file(template_path) 41 | 42 | output = template.to_string(entities) 43 | 44 | if wet 45 | File.write(output_path, output) 46 | else 47 | puts "*** #{output_path} ***" 48 | puts output 49 | end 50 | end 51 | end 52 | 53 | private 54 | 55 | def entites_json_path 56 | File.expand_path("entities.json", context.root) 57 | end 58 | -------------------------------------------------------------------------------- /bake/trenni/parsers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Generate the pure Ruby parsers. 4 | def generate_fallback 5 | Dir.chdir(fallback_directory) do 6 | Dir.glob("*.rl").each do |parser_path| 7 | system("ragel", "-I", parsers_directory, "-R", parser_path, "-F1") 8 | end 9 | 10 | # sh("ruby-beautify", "--tabs", "--overwrite", *Dir.glob("*.rb")) 11 | end 12 | end 13 | 14 | # Generate the native C parsers. 15 | def generate_native 16 | Dir.chdir(native_directory) do 17 | Dir.glob("*.rl").each do |parser_path| 18 | system("ragel", "-I", parsers_directory, "-C", parser_path, "-G2") 19 | end 20 | end 21 | end 22 | 23 | # Compile the C extension. 24 | def compile 25 | system("rake", "compile", chdir: extensions_directory) 26 | end 27 | 28 | # Generate the parsers and compile them as required. 29 | def generate 30 | self.generate_native 31 | self.generate_fallback 32 | self.compile 33 | end 34 | 35 | # Generate a visualisation of the parsers. 36 | def visualize_parsers 37 | Dir.chdir(fallback_directory) do 38 | Dir.glob("*.rl").each do |parser_path| 39 | dot_path = parser_path + ".dot" 40 | system("ragel", "-I", parsers_directory, "-Vp", parser_path, "-o", dot_path) 41 | 42 | pdf_path = parser_path + ".pdf" 43 | system("dot", "-Tpdf", "-o", pdf_path, dot_path) 44 | 45 | system("open", pdf_path) rescue nil 46 | end 47 | end 48 | end 49 | 50 | private 51 | 52 | def parsers_directory 53 | File.expand_path("parsers", context.root) 54 | end 55 | 56 | def fallback_directory 57 | File.expand_path("lib/trenni/fallback", context.root) 58 | end 59 | 60 | def extensions_directory 61 | File.expand_path("ext", context.root) 62 | end 63 | 64 | def native_directory 65 | File.expand_path("ext/trenni", context.root) 66 | end 67 | -------------------------------------------------------------------------------- /benchmark/call_vs_yield.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'benchmark/ips' 4 | 5 | puts "Ruby #{RUBY_VERSION} at #{Time.now}" 6 | puts 7 | 8 | firstname = 'soundarapandian' 9 | middlename = 'rathinasamy' 10 | lastname = 'arumugam' 11 | 12 | def do_call(&block) 13 | block.call 14 | end 15 | 16 | def do_yield(&block) 17 | yield 18 | end 19 | 20 | def do_yield_without_block 21 | yield 22 | end 23 | 24 | existing_block = proc{} 25 | 26 | Benchmark.ips do |x| 27 | x.report("block.call") do |i| 28 | buffer = String.new 29 | 30 | while (i -= 1) > 0 31 | do_call(&existing_block) 32 | end 33 | end 34 | 35 | x.report("yield with block") do |i| 36 | buffer = String.new 37 | 38 | while (i -= 1) > 0 39 | do_yield(&existing_block) 40 | end 41 | end 42 | 43 | x.report("yield") do |i| 44 | buffer = String.new 45 | 46 | while (i -= 1) > 0 47 | do_yield_without_block(&existing_block) 48 | end 49 | end 50 | 51 | x.compare! 52 | end 53 | -------------------------------------------------------------------------------- /benchmark/interpolation_vs_concat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'benchmark/ips' 4 | 5 | puts "Ruby #{RUBY_VERSION} at #{Time.now}" 6 | puts 7 | 8 | firstname = 'soundarapandian' 9 | middlename = 'rathinasamy' 10 | lastname = 'arumugam' 11 | 12 | Benchmark.ips do |x| 13 | x.report("String\#<<") do |i| 14 | buffer = String.new 15 | 16 | while (i -= 1) > 0 17 | buffer << 'Mr. ' << firstname << middlename << lastname << ' aka soundar' 18 | end 19 | end 20 | 21 | x.report("String interpolate") do |i| 22 | buffer = String.new 23 | 24 | while (i -= 1) > 0 25 | buffer << "Mr. #{firstname} #{middlename} #{lastname} aka soundar" 26 | end 27 | end 28 | 29 | x.compare! 30 | end 31 | -------------------------------------------------------------------------------- /benchmark/io_vs_string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'benchmark/ips' 4 | require 'stringio' 5 | 6 | puts "Ruby #{RUBY_VERSION} at #{Time.now}" 7 | puts 8 | 9 | Benchmark.ips do |x| 10 | 11 | # These two tests look at the cost of simply writing to a buffer. 12 | 13 | x.report("String\#<<") do |i| 14 | buffer = String.new 15 | 16 | while (i -= 1) > 0 17 | buffer << "String #{i}" 18 | end 19 | end 20 | 21 | x.report("Array\#<<") do |i| 22 | buffer = [] 23 | 24 | while (i -= 1) > 0 25 | buffer << "String #{i}" 26 | end 27 | 28 | buffer.join 29 | end 30 | 31 | x.report("StringIO") do |i| 32 | buffer = StringIO.new 33 | 34 | while (i -= 1) > 0 35 | buffer << "String #{i}" 36 | end 37 | end 38 | 39 | x.compare! 40 | end 41 | 42 | # Calculating ------------------------------------- 43 | # String (Amortized) 91666 i/100ms 44 | # StringIO (Amortized) 69531 i/100ms 45 | # ------------------------------------------------- 46 | # String (Amortized) 2856024.9 (±8.8%) i/s - 14208230 in 5.017309s 47 | # StringIO (Amortized) 2424863.3 (±7.0%) i/s - 12098394 in 5.013982s 48 | # 49 | # Comparison: 50 | # String (Amortized): 2856024.9 i/s 51 | # StringIO (Amortized): 2424863.3 i/s - 1.18x slower 52 | 53 | # Adjust N to consider the cost of allocation vs the cost of appending. 54 | N = 5 55 | 56 | Benchmark.ips do |x| 57 | # These next two tests consider that multiple writes may be done per buffer allocation. 58 | 59 | x.report("String") do |i| 60 | i.times do 61 | buffer = String.new 62 | 63 | N.times do 64 | buffer << "String #{i}" 65 | end 66 | end 67 | end 68 | 69 | x.report("StringIO") do |i| 70 | i.times do 71 | buffer = StringIO.new 72 | 73 | N.times do 74 | buffer << "String #{i}" 75 | end 76 | end 77 | end 78 | 79 | x.compare! 80 | end 81 | 82 | # Calculating ------------------------------------- 83 | # String 36822 i/100ms 84 | # StringIO 32471 i/100ms 85 | # ------------------------------------------------- 86 | # String 445143.2 (±5.0%) i/s - 2246142 in 5.059017s 87 | # StringIO 328469.2 (±4.1%) i/s - 1656021 in 5.049919s 88 | # 89 | # Comparison: 90 | # String: 445143.2 i/s 91 | # StringIO: 328469.2 i/s - 1.36x slower 92 | -------------------------------------------------------------------------------- /ext/trenni/escape.c: -------------------------------------------------------------------------------- 1 | 2 | #include "escape.h" 3 | #include"
" do 153 | include_context "valid markup" 154 | 155 | let(:template_text) {%q{#{events[3][1]}
}} 156 | let(:template_buffer) {Trenni::Buffer(template_text)} 157 | let(:template) {Trenni::MarkupTemplate.new(template_buffer)} 158 | 159 | it "should parse empty attributes" do 160 | expect(events).to be == [ 161 | [:open_tag_begin, "p", 1], 162 | [:attribute, "attr", "foo&bar"], 163 | [:open_tag_end, false], 164 | [:text, "\""], 165 | [:close_tag, "p", 30] 166 | ] 167 | end 168 | 169 | it "generates same output as input" do 170 | result = template.to_string(self) 171 | expect(result).to be == subject 172 | end 173 | 174 | it "should track entities" do 175 | expect(events[1][2]).to_not be_kind_of Trenni::Markup 176 | expect(events[3][1]).to_not be_kind_of Trenni::Markup 177 | end 178 | end 179 | 180 | RSpec.shared_examples "valid markup file" do |base| 181 | let(:xhtml_path) {File.join(__dir__, base + '.xhtml')} 182 | let(:events_path) {File.join(__dir__, base + '.rb')} 183 | 184 | subject {Trenni::FileBuffer.new(xhtml_path)} 185 | let(:expected_events) {eval(File.read(events_path), nil, events_path)} 186 | 187 | include_context "valid markup" 188 | 189 | def dump_events! 190 | File.open(events_path, "w+") do |output| 191 | output.puts "[" 192 | events.each do |event| 193 | output.puts "\t#{event.inspect}," 194 | end 195 | output.puts "]" 196 | end 197 | end 198 | 199 | it "should match events" do 200 | #dump_events! 201 | 202 | expected_events.each_with_index do |event, index| 203 | expect(events[index]).to be == event 204 | end 205 | end 206 | end 207 | 208 | RSpec.describe "corpus/large" do 209 | it_behaves_like "valid markup file", description 210 | end 211 | 212 | RSpec.shared_context "invalid markup" do 213 | include_context "html parsers" 214 | 215 | it "should fail to parse" do 216 | expect{events}.to raise_error Trenni::ParseError 217 | end 218 | end 219 | 220 | RSpec.describe "\nこんにちは World
= 0
14 | CGI.escapeHTML(general_string)
15 | end
16 | end
17 |
18 | x.report("CGI.escapeHTML(code_string)") do |times|
19 | while (times -= 1) >= 0
20 | CGI.escapeHTML(code_string)
21 | end
22 | end
23 |
24 | x.report("Trenni::Markup.escape_string(general_string)") do |times|
25 | while (times -= 1) >= 0
26 | Trenni::Markup.escape_string(general_string)
27 | end
28 | end
29 |
30 | x.report("Trenni::Markup.escape_string(code_string)") do |times|
31 | while (times -= 1) >= 0
32 | Trenni::Markup.escape_string(code_string)
33 | end
34 | end
35 |
36 | x.compare!
37 | end
38 | end
39 | end
--------------------------------------------------------------------------------
/spec/trenni/markup_spec.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env rspec
2 | # frozen_string_literal: true
3 |
4 | # Copyright, 2012, by Samuel G. D. Williams. Hello World
3 | #{i} bottles of beer on the wall, #{i} bottles of beer.
4 | Take one down and pass it around, #{i-1} bottles of beer on the wall.
5 | Hello World
"}
31 |
32 | it "should escape unsafe text" do
33 | model = double(text: html_text)
34 |
35 | expect(template.to_string(model)).to be == "<h1>Hello World</h1>"
36 | end
37 |
38 | let(:safe_html_text) {Trenni::Builder.tag('h1', 'Hello World')}
39 |
40 | it "should not escape safe text" do
41 | model = double(text: safe_html_text)
42 |
43 | expect(template.to_string(model)).to be == html_text
44 | end
45 |
46 | it "should convert nil to empty string" do
47 | Trenni::Markup.append(subject, nil)
48 |
49 | expect(subject).to be_empty
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/spec/trenni/parsers_performance_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'benchmark/ips'
4 | require 'trenni/parsers'
5 | require 'trenni/entities'
6 |
7 | require 'trenni/query'
8 | require 'rack/utils'
9 |
10 | require 'nokogiri'
11 |
12 | RSpec.describe Trenni::Parsers do
13 | # include_context "profile"
14 |
15 | describe '#parse_markup' do
16 | let(:xhtml_path) {File.expand_path('corpus/large.xhtml', __dir__)}
17 | let(:xhtml_buffer) {Trenni::FileBuffer.new(xhtml_path)}
18 | let(:entities) {Trenni::Entities::HTML5}
19 |
20 | it "should be fast to parse large documents" do
21 | Benchmark.ips do |x|
22 | x.report("Large (Trenni)") do |times|
23 | delegate = Trenni::ParseDelegate.new
24 |
25 | while (times -= 1) >= 0
26 | Trenni::Parsers.parse_markup(xhtml_buffer, delegate, entities)
27 |
28 | delegate.events.clear
29 | end
30 | end
31 |
32 | x.report("Large (Nokogiri)") do |times|
33 | delegate = Trenni::ParseDelegate.new
34 | parser = Nokogiri::HTML::SAX::Parser.new(delegate)
35 |
36 | while (times -= 1) >= 0
37 | parser.parse(xhtml_buffer.read)
38 |
39 | delegate.events.clear
40 | end
41 | end
42 |
43 | x.compare!
44 | end
45 | end
46 | end
47 |
48 | describe '#parse_template' do
49 | let(:large_trenni_path) {File.expand_path('template_spec/large.trenni', __dir__)}
50 | let(:trenni_buffer) {Trenni::FileBuffer.new(large_trenni_path)}
51 |
52 | let(:large_erb_path) {File.expand_path('template_spec/large.erb', __dir__)}
53 | let(:erb_buffer) {Trenni::FileBuffer.new(large_erb_path)}
54 |
55 | it "should be fast to parse large templates" do
56 | Benchmark.ips do |x|
57 | x.report("Large (Trenni)") do |times|
58 | delegate = Trenni::ParseDelegate.new
59 |
60 | while (times -= 1) >= 0
61 | Trenni::Parsers.parse_template(trenni_buffer, delegate)
62 |
63 | delegate.events.clear
64 | end
65 | end
66 |
67 | x.report("Large (ERB)") do |times|
68 | while (times -= 1) >= 0
69 | ERB.new(erb_buffer.read)
70 | end
71 | end
72 |
73 | x.compare!
74 | end
75 | end
76 | end
77 |
78 | describe '#parse_query' do
79 | let(:string) {"foo=hi%20there&bar[blah]=123&bar[quux][0]=1&bar[quux][1]=2&bar[quux][2]=3"}
80 |
81 | it "should be fast to parse large query strings" do
82 | # query = Trenni::Query.new
83 | # query.parse(Trenni::Buffer.new string)
84 | # pp query
85 | #
86 | # pp Rack::Utils.parse_nested_query(string)
87 |
88 | Benchmark.ips do |x|
89 | x.report("Large (Trenni)") do |times|
90 | while (times -= 1) >= 0
91 | Trenni::Query.new.parse(Trenni::Buffer.new string)
92 | end
93 | end
94 |
95 | x.report("Large (Rack)") do |times|
96 | while (times -= 1) >= 0
97 | Rack::Utils.parse_nested_query(string)
98 | end
99 | end
100 |
101 | x.compare!
102 | end
103 | end
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/spec/trenni/query_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Copyright, 2020, by Samuel G. D. Williams. '
67 | end
68 |
69 | describe Trenni::Reference("foo?bar=10&baz=20", yes: 'no') do
70 | it "can use existing query parameters" do
71 | expect(subject.to_s).to be == "foo?bar=10&baz=20&yes=no"
72 | end
73 | end
74 |
75 | describe Trenni::Reference("foo?yes=yes", yes: 'no') do
76 | it "overrides existing parameters" do
77 | expect(subject.to_s).to be == "foo?yes=no"
78 | end
79 | end
80 |
81 | describe Trenni::Reference('foo#frag') do
82 | it "can use existing fragment" do
83 | expect(subject.fragment).to be == "frag"
84 | expect(subject.to_s).to be == 'foo#frag'
85 | end
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/spec/trenni/strings_spec.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | # Copyright, 2012, by Samuel G. D. Williams.
')
139 |
140 | expect{
141 | subject.parse_template(buffer, delegate)
142 | }.to raise_error(Trenni::ParseError)
143 | end
144 |
145 | it "should fail to parse incomplete instruction" do
146 | buffer = Trenni::Buffer.new('
--------------------------------------------------------------------------------
/spec/trenni/template_spec/builder.trenni:
--------------------------------------------------------------------------------
1 | Hello World!
--------------------------------------------------------------------------------
/spec/trenni/template_spec/capture.trenni:
--------------------------------------------------------------------------------
1 |
2 | test test test
3 |
4 | #{result.upcase.inspect}
--------------------------------------------------------------------------------
/spec/trenni/template_spec/error.trenni:
--------------------------------------------------------------------------------
1 |
4 | #{error_on_line_4}
5 |
--------------------------------------------------------------------------------
/spec/trenni/template_spec/escaped.trenni:
--------------------------------------------------------------------------------
1 | This\nisn't one line.
2 | \tIndentation is the best.
--------------------------------------------------------------------------------
/spec/trenni/template_spec/interpolations.trenni:
--------------------------------------------------------------------------------
1 |
2 |
'
67 | end
68 |
69 | describe Trenni::URI("foo?bar=10&baz=20", yes: 'no') do
70 | it "can use existing query parameters" do
71 | expect(subject.to_s).to be == "foo?bar=10&baz=20&yes=no"
72 | end
73 | end
74 |
75 | describe Trenni::URI('foo#frag') do
76 | it "can use existing fragment" do
77 | expect(subject.fragment).to be == "frag"
78 | expect(subject.to_s).to be == 'foo#frag'
79 | end
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/trenni.gemspec:
--------------------------------------------------------------------------------
1 |
2 | require_relative "lib/trenni/version"
3 |
4 | Gem::Specification.new do |spec|
5 | spec.name = "trenni"
6 | spec.version = Trenni::VERSION
7 |
8 | spec.summary = "A fast native templating system that compiles directly to Ruby code."
9 | spec.authors = ["Samuel Williams"]
10 | spec.license = "MIT"
11 |
12 | spec.homepage = "https://github.com/ioquatix/trenni"
13 |
14 | spec.metadata = {
15 | "funding_uri" => "https://github.com/sponsors/ioquatix",
16 | }
17 |
18 | spec.files = Dir.glob('{bake,ext,lib,parsers,spec}/**/*', File::FNM_DOTMATCH, base: __dir__)
19 | spec.require_paths = ['lib']
20 |
21 | spec.extensions = ["ext/trenni/extconf.rb"]
22 |
23 | spec.required_ruby_version = ">= 2.5"
24 |
25 | spec.add_development_dependency "bake"
26 | spec.add_development_dependency "bundler"
27 | spec.add_development_dependency "covered"
28 | spec.add_development_dependency "rspec", "~> 3.4"
29 | end
30 |
--------------------------------------------------------------------------------