├── .tool-versions ├── Gemfile ├── .husky └── pre-commit ├── .gitignore ├── lib └── syntax_tree │ ├── erb │ ├── version.rb │ ├── visitor.rb │ ├── pretty_print.rb │ ├── format.rb │ ├── nodes.rb │ └── parser.rb │ └── erb.rb ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── general.md │ └── formatting-report.md └── workflows │ └── main.yml ├── package.json ├── test ├── fixture │ ├── nested_html_unformatted.html.erb │ ├── erb_inside_html_tag_formatted.html.erb │ ├── nested_html_formatted.html.erb │ ├── erb_inside_html_tag_unformatted.html.erb │ ├── case_unformatted.html.erb │ ├── case_formatted.html.erb │ ├── without_parens_unformatted.html.erb │ ├── if_statements_unformatted.html.erb │ ├── without_parens_formatted.html.erb │ ├── block_formatted.html.erb │ ├── block_unformatted.html.erb │ ├── if_statements_formatted.html.erb │ ├── javascript_frameworks_unformatted.html.erb │ ├── javascript_frameworks_formatted.html.erb │ ├── erb_syntax_unformatted.html.erb │ ├── erb_syntax_formatted.html.erb │ ├── layout_unformatted.html.erb │ └── layout_formatted.html.erb ├── test_helper.rb ├── formatting_test.rb ├── html_test.rb └── erb_test.rb ├── Rakefile ├── check_erb_parse.rb ├── .vscode └── launch.json ├── Gemfile.lock ├── LICENSE ├── syntax_tree-erb.gemspec ├── README.md └── CHANGELOG.md /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.3.1 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | test.erb 11 | -------------------------------------------------------------------------------- /lib/syntax_tree/erb/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | module ERB 5 | VERSION = "0.12.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General 3 | about: General issues 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "prepare": "husky install" 4 | }, 5 | "lint-staged": { 6 | "lib/**/*.rb": [ 7 | "bundle exec stree write" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixture/nested_html_unformatted.html.erb: -------------------------------------------------------------------------------- 1 |
<%= t(".title") + " " + t(".description") + " " + t(".pretty_long") %>'This is a single quote'"This is a double quote"
2 | -------------------------------------------------------------------------------- /test/fixture/erb_inside_html_tag_formatted.html.erb: -------------------------------------------------------------------------------- 1 |
2 | /> 3 | /> 4 | disabled<% end %> /> 5 | -------------------------------------------------------------------------------- /test/fixture/nested_html_formatted.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%= 4 | t(".title") + " " + t(".description") + " " + t(".pretty_long") 5 | %>'This is a single quote' 6 | "This is a double quote" 7 |
8 | -------------------------------------------------------------------------------- /test/fixture/erb_inside_html_tag_unformatted.html.erb: -------------------------------------------------------------------------------- 1 |
4 | > 5 | 7 | /> 8 | disabled<% end %> 10 | > 11 | -------------------------------------------------------------------------------- /test/fixture/case_unformatted.html.erb: -------------------------------------------------------------------------------- 1 | <%case variable%><%when 1 %>This is the first case.<% when 2 %> 2 | This is the second case. 3 | <% when 3%> 4 | This is the third case. 5 | <% when 4, 5 %> 6 | This is the fourth and fifth case. 7 | <% else %> 8 | This is the default case. 9 | <% end%> 10 | -------------------------------------------------------------------------------- /test/fixture/case_formatted.html.erb: -------------------------------------------------------------------------------- 1 | <% case variable %> 2 | <% when 1 %> 3 | This is the first case. 4 | <% when 2 %> 5 | This is the second case. 6 | <% when 3 %> 7 | This is the third case. 8 | <% when 4, 5 %> 9 | This is the fourth and fifth case. 10 | <% else %> 11 | This is the default case. 12 | <% end %> 13 | -------------------------------------------------------------------------------- /test/fixture/without_parens_unformatted.html.erb: -------------------------------------------------------------------------------- 1 | <%= this_is 'short', 'enough' %> 2 | <% if condition? this_is_short then x else y end %> 3 | <% if condition? this_is_pretty_long, so_we_probably, cant_fit_all_arguments, in_one_line %> 4 | <%= render 'some_partial', param_one: 1, param_two: 2, param_three: 3, param_four: 4, param_five: 5 %> 5 | <% end %> 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require "syntax_tree/rake_tasks" 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << "test" 9 | t.libs << "lib" 10 | t.test_files = FileList["test/**/*_test.rb"] 11 | end 12 | 13 | SyntaxTree::Rake::CheckTask.new 14 | SyntaxTree::Rake::WriteTask.new 15 | 16 | task default: :test 17 | -------------------------------------------------------------------------------- /check_erb_parse.rb: -------------------------------------------------------------------------------- 1 | #!/bin/ruby 2 | 3 | require "syntax_tree/erb" 4 | 5 | failures = [] 6 | 7 | Dir 8 | .glob("./app/**/*.html.erb") 9 | .each do |file| 10 | puts("Processing #{file}") 11 | begin 12 | source = SyntaxTree::ERB.read(file) 13 | SyntaxTree::ERB.parse(source) 14 | SyntaxTree::ERB.format(source) 15 | rescue => exception 16 | failures << { file: file, message: exception.message } 17 | end 18 | end 19 | 20 | puts failures 21 | -------------------------------------------------------------------------------- /test/fixture/if_statements_unformatted.html.erb: -------------------------------------------------------------------------------- 1 | <%if this%>

that

<%elsif that %>

this

2 | <% if nested_this %>

this

<%end%><%else%>

else

<%end %> 3 | <%= what if this %> 4 |

<% unless what %> Ja<% elsif allowed? %> 5 | Nej<% else %>Kanske 6 | <% end %>

7 | 8 | <%= @model ? @model.name : t("views.more_than_80_characters_long_row.categories.shared.version.default") %> 9 | 10 |
11 | -------------------------------------------------------------------------------- /test/fixture/without_parens_formatted.html.erb: -------------------------------------------------------------------------------- 1 | <%= this_is "short", "enough" %> 2 | <% 3 | if condition? this_is_short 4 | x 5 | else 6 | y 7 | end 8 | %> 9 | <% 10 | if condition? this_is_pretty_long, 11 | so_we_probably, 12 | cant_fit_all_arguments, 13 | in_one_line 14 | %> 15 | <%= 16 | render "some_partial", 17 | param_one: 1, 18 | param_two: 2, 19 | param_three: 3, 20 | param_four: 4, 21 | param_five: 5 22 | %> 23 | <% end %> 24 | -------------------------------------------------------------------------------- /test/fixture/block_formatted.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(url: format_path) do |form| %> 2 |
3 | <%= form.label(:name, "Name") %> 4 | <%= form.text_field(:name, class: "form-control") %> 5 |
6 | 7 | <%= 8 | form.submit( 9 | "Very very very very very long text", 10 | class: "btn btn-primary btn btn-primary btn btn-primary btn btn-primary" 11 | ) 12 | %> 13 | <% end %> 14 | 15 | <%= link_to(dont_replace, what_to_do, class: "do |what,bad|") do |hello| %> 16 | Should allow to use the word do in the code 17 | <% end %> 18 | 19 | <%= this_is_not_a_do_block_do %> 20 | -------------------------------------------------------------------------------- /test/fixture/block_unformatted.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with(url: format_path) do |form| %> 2 |
3 | <%=form.label(:name, "Name")%> 4 | <%=form.text_field(:name, class: "form-control")%> 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | <%= form.submit("Very very very very very long text", class: "btn btn-primary btn btn-primary btn btn-primary btn btn-primary") %> 19 | <% end %> 20 | 21 | <%= link_to(dont_replace, what_to_do, class: 'do |what,bad|') do |hello| %>Should allow to use the word do in the code 22 | <% end %> 23 | 24 | <%= this_is_not_a_do_block_do %> 25 | -------------------------------------------------------------------------------- /test/fixture/if_statements_formatted.html.erb: -------------------------------------------------------------------------------- 1 | <% if this %> 2 |

that

3 | <% elsif that %> 4 |

this

5 | <% if nested_this %> 6 |

this

7 | <% end %> 8 | <% else %> 9 |

else

10 | <% end %> 11 | <%= what if this %> 12 |

13 | <% unless what %> 14 | Ja 15 | <% elsif allowed? %> 16 | Nej 17 | <% else %> 18 | Kanske 19 | <% end %> 20 |

21 | 22 | <%= 23 | if @model 24 | @model.name 25 | else 26 | t("views.more_than_80_characters_long_row.categories.shared.version.default") 27 | end 28 | %> 29 | 30 |
31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/formatting-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Formatting report 3 | about: Create a report to help us improve 4 | title: "[Formatting] " 5 | labels: bug 6 | assignees: davidwessman 7 | 8 | --- 9 | 10 |
11 | ERB-snippet 12 | 13 | ```html 14 | <% code %> 15 | ``` 16 |
17 | 18 |
19 | Expected formatting 20 | 21 | ```html 22 | <% code %> 23 | ``` 24 |
25 |
26 | Actual formatting 27 | 28 | ```html 29 | <% code %> 30 | ``` 31 |
32 | 33 | ## Comment 34 | 35 | ## Versions 36 | syntax_tree: 37 | syntax_tree-erb: 38 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "ruby_lsp", 9 | "name": "Debug file", 10 | "request": "launch", 11 | "program": "ruby ${file}" 12 | }, 13 | { 14 | "type": "ruby_lsp", 15 | "name": "Debug test", 16 | "request": "launch", 17 | "program": "ruby -Itest ${relativeFile}" 18 | }, 19 | { 20 | "type": "ruby_lsp", 21 | "name": "Debug attach", 22 | "request": "attach" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | SimpleCov.start 5 | 6 | $:.unshift File.expand_path("../lib", __dir__) 7 | require "syntax_tree/erb" 8 | 9 | require "minitest/autorun" 10 | 11 | class TestCase < Minitest::Test 12 | def assert_formatting(source, expected) 13 | formatted = SyntaxTree::ERB.format(source) 14 | 15 | assert_equal(formatted, expected, "Failed first") 16 | 17 | formatted_twice = SyntaxTree::ERB.format(formatted) 18 | 19 | assert_equal(formatted_twice, expected, "Failed second") 20 | 21 | # Check that pretty_print works 22 | output = SyntaxTree::ERB.parse(expected).pretty_inspect 23 | refute_predicate(output, :empty?) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/fixture/javascript_frameworks_unformatted.html.erb: -------------------------------------------------------------------------------- 1 | 2 |
" boolean :value="['a', 'b']" :long-variable-name="data.item.javascript.code"> 3 | 4 |
5 | 6 | 7 | 8 | 9 |
10 |

Hello

11 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | jobs: 11 | ci: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby: 16 | - "3.0" 17 | - "3.1" 18 | - "3.2" 19 | - "3.3" 20 | name: CI 21 | runs-on: ubuntu-latest 22 | env: 23 | CI: true 24 | steps: 25 | - uses: actions/checkout@master 26 | - uses: ruby/setup-ruby@v1 27 | with: 28 | bundler-cache: true 29 | ruby-version: ${{ matrix.ruby }} 30 | - name: Test 31 | run: | 32 | bundle exec rake test 33 | bundle exec rake stree:check 34 | -------------------------------------------------------------------------------- /lib/syntax_tree/erb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "prettier_print" 4 | require "syntax_tree" 5 | 6 | require_relative "erb/nodes" 7 | require_relative "erb/parser" 8 | require_relative "erb/visitor" 9 | 10 | require_relative "erb/format" 11 | require_relative "erb/pretty_print" 12 | 13 | module SyntaxTree 14 | module ERB 15 | MAX_WIDTH = 80 16 | def self.format(source, maxwidth = MAX_WIDTH, options: nil) 17 | PrettierPrint.format(+"", maxwidth) { |q| parse(source).format(q) } 18 | end 19 | 20 | def self.parse(source) 21 | Parser.new(source).parse 22 | end 23 | 24 | def self.read(filepath) 25 | File.read(filepath) 26 | end 27 | end 28 | 29 | register_handler(".html.erb", ERB) 30 | register_handler(".erb", ERB) 31 | end 32 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | w_syntax_tree-erb (0.12.0) 5 | prettier_print (~> 1.2, >= 1.2.0) 6 | syntax_tree (~> 6.1, >= 6.1.1) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | docile (1.4.0) 12 | minitest (5.26.1) 13 | prettier_print (1.2.1) 14 | rake (13.3.1) 15 | simplecov (0.22.0) 16 | docile (~> 1.1) 17 | simplecov-html (~> 0.11) 18 | simplecov_json_formatter (~> 0.1) 19 | simplecov-html (0.12.3) 20 | simplecov_json_formatter (0.1.4) 21 | syntax_tree (6.3.0) 22 | prettier_print (>= 1.2.0) 23 | 24 | PLATFORMS 25 | arm64-darwin-21 26 | arm64-darwin-23 27 | x86_64-darwin-21 28 | x86_64-darwin-22 29 | x86_64-linux 30 | 31 | DEPENDENCIES 32 | bundler (~> 2) 33 | minitest (~> 5) 34 | rake (~> 13) 35 | simplecov (~> 0.22) 36 | w_syntax_tree-erb! 37 | 38 | BUNDLED WITH 39 | 2.4.1 40 | -------------------------------------------------------------------------------- /test/fixture/javascript_frameworks_formatted.html.erb: -------------------------------------------------------------------------------- 1 | 2 |
3 | " 6 | boolean 7 | :value="['a', 'b']" 8 | :long-variable-name="data.item.javascript.code" 9 | > 10 | 11 | 12 | 13 | 14 |
15 | 16 | 20 | 21 | 22 |
28 | 29 | 30 |

Hello

31 |
32 |
33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Kevin Newton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixture/erb_syntax_unformatted.html.erb: -------------------------------------------------------------------------------- 1 | <% this = "avoids line break after expression"-%> 2 | <%# This is an ERB-comment https://stackoverflow.com/a/25626629 this answer describes ERB and erubis syntax%> 3 | <%== rails_raw_output%> 4 | <%-"this only works in ERB not erubis"%> 5 | <% # This should be written on one line %> 6 | <%# 7 | This is a comment 8 | It can be mutiline 9 | Treat it as a comment 10 | %> 11 | 12 | <% if this -%> 13 | <%= form.submit -%> 14 | <% elsif that -%> 15 | <%= form.submit -%> 16 | <% else -%> 17 | <%= form.submit -%> 18 | <% end -%> 19 | 20 | <%- if this %> 21 | <%= form.submit -%> 22 | <%- elsif that %> 23 | <%= form.submit -%> 24 | <%- else %> 25 | <%= form.submit -%> 26 | <%- end %> 27 | 28 | <%= link_to(link, text) do -%> 29 |

Cool

30 | <%- end %> 31 | 32 | 33 | <%= t( 34 | ".verified_at", 35 | at: 36 | ( 37 | if @repository.github_status_at 38 | l(@repository.github_status_at, format: :long) 39 | else 40 | "?" 41 | end 42 | ) 43 | ) %> 44 | 45 | <% 46 | assign_b ||= "b" 47 | assign_c ||= "c" 48 | %> 49 | 50 |
mt-<%=5 * 5%>">
51 | -------------------------------------------------------------------------------- /test/fixture/erb_syntax_formatted.html.erb: -------------------------------------------------------------------------------- 1 | <% this = "avoids line break after expression" -%> 2 | <%# This is an ERB-comment https://stackoverflow.com/a/25626629 this answer describes ERB and erubis syntax%> 3 | <%== rails_raw_output %> 4 | <%- "this only works in ERB not erubis" %> 5 | <% # This should be written on one line %> 6 | <%# 7 | This is a comment 8 | It can be mutiline 9 | Treat it as a comment 10 | %> 11 | 12 | <% if this -%> 13 | <%= form.submit -%> 14 | <% elsif that -%> 15 | <%= form.submit -%> 16 | <% else -%> 17 | <%= form.submit -%> 18 | <% end -%> 19 | 20 | <%- if this %> 21 | <%= form.submit -%> 22 | <%- elsif that %> 23 | <%= form.submit -%> 24 | <%- else %> 25 | <%= form.submit -%> 26 | <%- end %> 27 | 28 | <%= link_to(link, text) do -%> 29 |

Cool

30 | <%- end %> 31 | 32 | <%= 33 | t( 34 | ".verified_at", 35 | at: 36 | ( 37 | if @repository.github_status_at 38 | l(@repository.github_status_at, format: :long) 39 | else 40 | "?" 41 | end 42 | ) 43 | ) 44 | %> 45 | 46 | <% 47 | assign_b ||= "b" 48 | assign_c ||= "c" 49 | %> 50 | 51 |
mt-<%= 5 * 5 %>">
52 | -------------------------------------------------------------------------------- /syntax_tree-erb.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/syntax_tree/erb/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "w_syntax_tree-erb" 7 | spec.version = SyntaxTree::ERB::VERSION 8 | spec.authors = ["Kevin Newton", "David Wessman"] 9 | spec.email = %w[kddnewton@gmail.com david@wessman.co] 10 | 11 | spec.summary = "Syntax Tree support for ERB" 12 | spec.homepage = "https://github.com/davidwessman/syntax_tree-erb" 13 | spec.license = "MIT" 14 | spec.metadata = { "rubygems_mfa_required" => "true" } 15 | spec.required_ruby_version = ">= 3.0.0" 16 | 17 | spec.files = 18 | Dir.chdir(__dir__) do 19 | `git ls-files -z`.split("\x0") 20 | .reject { |f| f.match(%r{^(test|spec|features)/}) } 21 | end 22 | 23 | spec.bindir = "exe" 24 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 25 | spec.require_paths = %w[lib] 26 | 27 | spec.add_runtime_dependency "prettier_print", "~> 1.2", ">= 1.2.0" 28 | spec.add_runtime_dependency "syntax_tree", "~> 6.1", ">= 6.1.1" 29 | 30 | spec.add_development_dependency "bundler", "~> 2" 31 | spec.add_development_dependency "minitest", "~> 5" 32 | spec.add_development_dependency "rake", "~> 13" 33 | spec.add_development_dependency "simplecov", "~> 0.22" 34 | end 35 | -------------------------------------------------------------------------------- /test/fixture/layout_unformatted.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= full_title(t('general.title'), yield(:title)) %> 8 | <%= stylesheet_link_tag('application', media: 'all', 'data-turbolinks-track': 'reload') %> 9 | <%= javascript_include_tag('application', 'data-turbolinks-track': 'reload', defer: true) %> 10 | <%= csrf_meta_tags %> 11 | <%= csp_meta_tag %> 12 | <%= render('application/favicon') %> 13 | 14 | 15 | 16 |
17 | <%= render('header') %> 18 | 19 |
20 | <%= yield(:sidebar) %> 21 |
22 | <%= render('application/heading') %> 23 | <%= render('flashes') %> 24 | <%= yield %> 25 |
26 |
27 | 28 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /test/fixture/layout_formatted.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | <%= full_title(t("general.title"), yield(:title)) %> 14 | <%= 15 | stylesheet_link_tag( 16 | "application", 17 | media: "all", 18 | "data-turbolinks-track": "reload" 19 | ) 20 | %> 21 | <%= 22 | javascript_include_tag( 23 | "application", 24 | "data-turbolinks-track": "reload", 25 | defer: true 26 | ) 27 | %> 28 | <%= csrf_meta_tags %> 29 | <%= csp_meta_tag %> 30 | <%= render("application/favicon") %> 31 | 32 | 33 | 34 |
35 | <%= render("header") %> 36 | 37 |
38 | <%= yield(:sidebar) %> 39 |
40 | <%= render("application/heading") %> 41 | <%= render("flashes") %> 42 | <%= yield %> 43 |
44 |
45 | 46 | 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /lib/syntax_tree/erb/visitor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | module ERB 5 | # Provides a visitor interface for visiting certain nodes. It's used 6 | # internally to implement formatting and pretty-printing. It could also be 7 | # used externally to visit a subset of nodes that are relevant to a certain 8 | # task. 9 | class Visitor < SyntaxTree::Visitor 10 | def visit(node) 11 | node&.accept(self) 12 | end 13 | 14 | alias visit_statements visit_child_nodes 15 | 16 | private 17 | 18 | def visit_all(nodes) 19 | nodes.map { |node| visit(node) } 20 | end 21 | 22 | def visit_child_nodes(node) 23 | visit_all(node.child_nodes) 24 | end 25 | 26 | # Visit a Token node. 27 | alias visit_token visit_child_nodes 28 | 29 | # Visit a Document node. 30 | alias visit_document visit_child_nodes 31 | 32 | # Visit an Html node. 33 | alias visit_html visit_child_nodes 34 | 35 | # Visit an HtmlNode::OpeningTag node. 36 | alias visit_opening_tag visit_child_nodes 37 | 38 | # Visit an HtmlNode::ClosingTag node. 39 | alias visit_closing_tag visit_child_nodes 40 | 41 | # Visit an Attribute node. 42 | alias visit_attribute visit_child_nodes 43 | 44 | # Visit a CharData node. 45 | alias visit_char_data visit_child_nodes 46 | 47 | # Visit an ErbNode node. 48 | alias visit_erb visit_child_nodes 49 | 50 | # Visit a HtmlString node. 51 | alias visit_html_string visit_child_nodes 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/formatting_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module SyntaxTree 6 | class FormattingTest < Minitest::Test 7 | def test_block 8 | assert_formatting("block") 9 | end 10 | 11 | def test_erb_syntax 12 | assert_formatting("erb_syntax") 13 | end 14 | 15 | def test_nested_html 16 | assert_formatting("nested_html") 17 | end 18 | 19 | def test_if_statements 20 | assert_formatting("if_statements") 21 | end 22 | 23 | def test_javascript_frameworks 24 | assert_formatting("javascript_frameworks") 25 | end 26 | 27 | def test_case_statements 28 | assert_formatting("case") 29 | end 30 | 31 | def test_layout 32 | assert_formatting("layout") 33 | end 34 | 35 | def test_method_calls_without_parens 36 | assert_formatting("without_parens") 37 | end 38 | 39 | def test_erb_inside_html_tag 40 | assert_formatting("erb_inside_html_tag") 41 | end 42 | 43 | private 44 | 45 | def assert_formatting(name) 46 | directory = File.expand_path("fixture", __dir__) 47 | unformatted_file = File.join(directory, "#{name}_unformatted.html.erb") 48 | formatted_file = File.join(directory, "#{name}_formatted.html.erb") 49 | source = SyntaxTree::ERB.read(unformatted_file) 50 | 51 | expected = SyntaxTree::ERB.read(formatted_file) 52 | formatted = SyntaxTree::ERB.format(source) 53 | 54 | if (expected != formatted) 55 | puts("Failed to format #{name}, see ./tmp/#{name}_failed.html.erb") 56 | Dir.mkdir("./tmp") unless Dir.exist?("./tmp") 57 | File.write("./tmp/#{name}_failed.html.erb", formatted) 58 | end 59 | 60 | assert_equal(formatted, expected) 61 | 62 | formatted_twice = SyntaxTree::ERB.format(formatted) 63 | assert_equal(formatted_twice, expected) 64 | 65 | # Check that pretty_print works 66 | output = SyntaxTree::ERB.parse(expected).pretty_inspect 67 | refute_predicate(output, :empty?) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SyntaxTree::ERB 2 | 3 | [![Build Status](https://github.com/davidwessman/syntax_tree-erb/actions/workflows/main.yml/badge.svg)](https://github.com/davidwessman/syntax_tree-erb/actions/workflows/main.yml) 4 | 5 | [Syntax Tree](https://github.com/ruby-syntax-tree/syntax_tree) support for ERB. 6 | 7 | # Deprecated 8 | 9 | This gem was a lot of fun to implement and taught me a lot about syntax trees and parsing code. 10 | Deciding how to handle personal preferences, whitespaces and edge-cases is really hard. 11 | 12 | This will never be implemented in [syntax_tree](https://github.com/ruby-syntax-tree/syntax_tree/issues/383) but 13 | I hope that [HERB](https://github.com/marcoroth/herb) will become a perfect tool for the Ruby-ecosystem. 14 | 15 | **I will not do any more changes.** 16 | 17 | / David, 2025-08-24 18 | 19 | ---- 20 | 21 | Currently handles 22 | 23 | - ERB 24 | - Tags with and without output 25 | - Tags inside strings 26 | - `if`, `elsif`, `else` and `unless` statements 27 | - blocks 28 | - comments 29 | - Formatting of the ruby-code is done by `syntax_tree` 30 | - HTML 31 | - Tags with attributes 32 | - Tags with and without closing tags 33 | - Comments 34 | - Vue 35 | - Attributes, events and slots using `:`, `@` and `#` respectively 36 | - Text output 37 | 38 | ## Unhandled cases 39 | 40 | - Please add to this pinned issue (https://github.com/davidwessman/syntax_tree-erb/issues/28) or create a separate issue if you encounter formatting or parsing errors. 41 | 42 | ## Installation 43 | 44 | Add this line to your application's Gemfile: 45 | 46 | ```ruby 47 | gem "w_syntax_tree-erb", "~> 0.12", require: false 48 | ``` 49 | 50 | > I added the `w_` prefix to avoid conflicts if there will ever be an official `syntax_tree-erb` gem. 51 | 52 | ## Usage 53 | 54 | ### Parsing 55 | 56 | ```sh 57 | bundle exec stree ast --plugins=erb "./**/*.html.erb" 58 | ``` 59 | 60 | ### Format 61 | 62 | ```sh 63 | bundle exec stree write --plugins=erb "./**/*.html.erb" 64 | ``` 65 | 66 | ### In code 67 | 68 | ```ruby 69 | require "syntax_tree/erb" 70 | 71 | pp SyntaxTree::ERB.parse(source) # print out the AST 72 | puts SyntaxTree::ERB.format(source) # format the AST 73 | ``` 74 | 75 | ## List all parsing errors 76 | 77 | In order to get a list of all parsing errors (which needs to be fixed before the formatting works), this script can be used: 78 | 79 | ```ruby 80 | #!/bin/ruby 81 | 82 | require "syntax_tree/erb" 83 | 84 | failures = [] 85 | 86 | Dir 87 | .glob("./app/**/*.html.erb") 88 | .each do |file| 89 | puts("Processing #{file}") 90 | begin 91 | source = SyntaxTree::ERB.read(file) 92 | SyntaxTree::ERB.parse(source) 93 | SyntaxTree::ERB.format(source) 94 | rescue => exception 95 | failures << { file: file, message: exception.message } 96 | end 97 | end 98 | 99 | puts failures 100 | ``` 101 | 102 | ## Development 103 | 104 | Install `husky`: 105 | 106 | ```sh 107 | npm i -g husky 108 | ``` 109 | 110 | Setup linting: 111 | 112 | ```sh 113 | npm run prepare 114 | ``` 115 | 116 | Install dependencies and run tests: 117 | 118 | ```sh 119 | bundle 120 | bundle exec rake 121 | ``` 122 | 123 | ## Contributing 124 | 125 | Bug reports and pull requests are welcome on GitHub at https://github.com/davidwessman/syntax_tree-erb. 126 | 127 | ## License 128 | 129 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 130 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.12.0] - 2024-05-07 10 | 11 | - Support Ruby 3.3 by handling yield in ERB specifically 12 | - Use SyntaxTree own class for ParseError to get better error feedback 13 | - Adds handling for keeping track of column index, to support better error messages 14 | 15 | ## [0.11.0] - 2024-04-23 16 | 17 | - ErbContent now has its value as child_nodes instead of empty array. 18 | - Allow html void tags and format self-closing tags 19 | 20 | ## [0.10.5] - 2023-09-03 21 | 22 | - Handle ERB-tags inside HTML-tags, like `
>` 23 | - Handles indentation for multiline ERB-comment 24 | - Handles spaces between do-arguments and ERB-tags 25 | 26 | ## [0.10.4] - 2023-08-28 27 | 28 | - Avoid grouping single tags 29 | - Handle multiline ERB-comments 30 | 31 | ## [0.10.3] - 2023-08-27 32 | 33 | ## Fixes 34 | 35 | - Allows parsing ERB-tags with if, else and end in the same tag 36 | 37 | ```erb 38 | <%= if true 39 | what 40 | end %> 41 | ``` 42 | 43 | This opens the possibility for formatting all if-statements with SyntaxTree properly 44 | and removes the fix where any if-statement was force to one line. 45 | 46 | ## [0.10.2] - 2023-08-22 47 | 48 | ### Fixes 49 | 50 | - Handles formatting empty documents and removing leading new-linews in files with content. 51 | - Removes trailing whitespace from char data if it is the last element in a document, block or group. 52 | 53 | ## [0.10.1] - 2023-08-20 54 | 55 | ### Added 56 | 57 | - Allow `DOCTYPE` to be after other tags, to work with e.g. ERB-tags on first line. 58 | 59 | ## [0.10.0] - 2023-08-20 60 | 61 | - Changes how whitespace and newlines are handled. 62 | - Supports syntax like: 63 | 64 | ```erb 65 | <%= part %> / <%= total %> (<%= percentage %>%) 66 | ``` 67 | 68 | ## [0.9.5] - 2023-07-02 69 | 70 | - Fixes ruby comment in ERB-tag included VoidStatement 71 | Example: 72 | 73 | ```erb 74 | <% # this is a comment %> 75 | ``` 76 | 77 | Output: 78 | 79 | ```diff 80 | -<% 81 | - 82 | - # this is a comment 83 | -%> 84 | +<% # this is a comment %> 85 | ``` 86 | 87 | - Updates versions in Bundler 88 | 89 | ## [0.9.4] - 2023-07-01 90 | 91 | - Inline even more empty HTML-tags 92 | 93 | ```diff 94 | 99 | - 100 | +> 101 | ``` 102 | 103 | ## [0.9.3] - 2023-06-30 104 | 105 | - Print empty html-tags on one line if possible 106 | 107 | ## [0.9.2] - 2023-06-30 108 | 109 | - Handle whitespace in HTML-strings using ERB-tags 110 | 111 | ## [0.9.1] - 2023-06-28 112 | 113 | - Handle formatting of multi-line ERB-tags with more than one statement. 114 | 115 | ## [0.9.0] - 2023-06-22 116 | 117 | ### Added 118 | 119 | - 🎉 First version based on syntax_tree-xml 🎉. 120 | - Can format a lot of .html.erb-syntax and works as a plugin to syntax_tree. 121 | - This is still early and there are a lot of different weird syntaxes out there. 122 | 123 | [unreleased]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.12.0...HEAD 124 | [0.12.0]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.11.0...v0.12.0 125 | [0.11.0]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.10.5...v0.11.0 126 | [0.10.5]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.10.4...v0.10.5 127 | [0.10.4]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.10.3...v0.10.4 128 | [0.10.3]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.10.2...v0.10.3 129 | [0.10.2]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.10.1...v0.10.2 130 | [0.10.1]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.10.0...v0.10.1 131 | [0.10.0]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.9.5...v0.10.0 132 | [0.9.5]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.9.4...v0.9.5 133 | [0.9.4]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.9.3...v0.9.4 134 | [0.9.3]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.9.2...v0.9.3 135 | [0.9.2]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.9.1...v0.9.2 136 | [0.9.1]: https://github.com/davidwessman/syntax_tree-erb/compare/v0.9.0...v0.9.1 137 | [0.9.0]: https://github.com/davidwessman/syntax_tree-erb/compare/419727a73af94057ca0980733e69ac8b4d52fdf4...v0.9.0 138 | -------------------------------------------------------------------------------- /lib/syntax_tree/erb/pretty_print.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SyntaxTree 4 | module ERB 5 | class PrettyPrint < Visitor 6 | attr_reader :q 7 | 8 | def initialize(q) 9 | @q = q 10 | end 11 | 12 | # Visit a Token node. 13 | def visit_token(node) 14 | q.pp(node.value) 15 | end 16 | 17 | # Visit a Document node. 18 | def visit_document(node) 19 | visit_node("document", node) 20 | end 21 | 22 | def visit_block(node) 23 | visit_node(node.class.to_s, node) 24 | end 25 | 26 | # Visit an HtmlNode. 27 | def visit_html(node) 28 | visit_node("html", node) 29 | end 30 | 31 | # Visit an HtmlNode::OpeningTag node. 32 | def visit_opening_tag(node) 33 | visit_node("opening_tag", node) 34 | end 35 | 36 | # Visit an HtmlNode::ClosingTag node. 37 | def visit_closing_tag(node) 38 | visit_node("closing_tag", node) 39 | end 40 | 41 | # Visit an ErbNode node. 42 | def visit_erb(node) 43 | q.group do 44 | q.text("(erb") 45 | q.nest(2) do 46 | q.breakable 47 | visit(node.opening_tag) 48 | if node.keyword 49 | q.breakable 50 | visit(node.keyword) 51 | end 52 | if node.content 53 | q.breakable 54 | visit(node.content) 55 | end 56 | 57 | q.breakable 58 | visit(node.closing_tag) 59 | end 60 | q.breakable("") 61 | q.text(")") 62 | end 63 | end 64 | 65 | def visit_erb_block(node) 66 | q.group do 67 | q.text("(erb_block") 68 | q.nest(2) do 69 | q.breakable 70 | visit(node.opening) 71 | q.seplist(node.elements) { |child_node| visit(child_node) } 72 | end 73 | q.breakable 74 | visit(node.closing) 75 | q.breakable("") 76 | q.text(")") 77 | end 78 | end 79 | 80 | def visit_erb_if(node, key = "erb_if") 81 | q.group do 82 | q.text("(") 83 | visit(node.opening) 84 | q.nest(2) do 85 | q.breakable() 86 | q.seplist(node.child_nodes) { |child_node| visit(child_node) } 87 | end 88 | q.breakable 89 | visit(node.closing) 90 | q.breakable("") 91 | q.text(")") 92 | end 93 | end 94 | 95 | def visit_erb_elsif(node) 96 | visit_erb_if(node, "erb_elsif") 97 | end 98 | 99 | def visit_erb_else(node) 100 | visit_erb_if(node, "erb_else") 101 | end 102 | 103 | def visit_erb_case(node) 104 | visit_erb_if(node, "erb_case") 105 | end 106 | 107 | def visit_erb_case_when(node) 108 | visit_erb_if(node, "erb_when") 109 | end 110 | 111 | def visit_erb_end(node) 112 | q.pp("(erb_end)") 113 | end 114 | 115 | # Visit an ErbContent node. 116 | def visit_erb_content(node) 117 | q.pp(node.value) 118 | end 119 | 120 | # Visit an Attribute node. 121 | def visit_attribute(node) 122 | visit_node("attribute", node) 123 | end 124 | 125 | # Visit a HtmlString node. 126 | def visit_html_string(node) 127 | visit_node("html_string", node) 128 | end 129 | 130 | # Visit a CharData node. 131 | def visit_char_data(node) 132 | visit_node("char_data", node) 133 | end 134 | 135 | def visit_new_line(node) 136 | node.count > 1 ? q.text("(new_line blank)") : q.text("(new_line)") 137 | end 138 | 139 | def visit_erb_close(node) 140 | visit(node.closing) 141 | end 142 | 143 | def visit_erb_do_close(node) 144 | visit_node("erb_do_close", node) 145 | end 146 | 147 | def visit_erb_yield(node) 148 | visit_node("erb_yield", node) 149 | end 150 | 151 | # Visit a Doctype node. 152 | def visit_doctype(node) 153 | visit_node("doctype", node) 154 | end 155 | 156 | def visit_html_comment(node) 157 | visit_node("html_comment", node) 158 | end 159 | 160 | def visit_erb_comment(node) 161 | visit_node("erb_comment", node) 162 | end 163 | 164 | private 165 | 166 | # A generic visit node function for how we pretty print nodes. 167 | def visit_node(type, node) 168 | q.group do 169 | q.text("(#{type}") 170 | q.nest(2) do 171 | q.breakable 172 | q.seplist(node.child_nodes) { |child_node| visit(child_node) } 173 | end 174 | q.breakable("") 175 | q.text(")") 176 | end 177 | end 178 | 179 | def comments(node) 180 | return if node.comments.empty? 181 | 182 | q.breakable 183 | q.group(2, "(", ")") do 184 | q.seplist(node.comments) { |comment| q.pp(comment) } 185 | end 186 | end 187 | 188 | def field(_name, value) 189 | q.breakable 190 | q.pp(value) 191 | end 192 | 193 | def list(_name, values) 194 | q.breakable 195 | q.group(2, "(", ")") { q.seplist(values) { |value| q.pp(value) } } 196 | end 197 | 198 | def node(_node, type) 199 | q.group(2, "(", ")") do 200 | q.text(type) 201 | yield 202 | end 203 | end 204 | 205 | def pairs(_name, values) 206 | q.group(2, "(", ")") do 207 | q.seplist(values) do |(key, value)| 208 | q.pp(key) 209 | 210 | if value 211 | q.text("=") 212 | q.group(2) do 213 | q.breakable("") 214 | q.pp(value) 215 | end 216 | end 217 | end 218 | end 219 | end 220 | 221 | def text(_name, value) 222 | q.breakable 223 | q.text(value) 224 | end 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /test/html_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module SyntaxTree 6 | class HtmlTest < TestCase 7 | def test_html_wrong_end_tag 8 | example = <<~HTML 9 |
10 |
16 | HTML 17 | ERB.parse(example) 18 | rescue SyntaxTree::Parser::ParseError => error 19 | assert_equal(7, error.lineno) 20 | assert_equal(0, error.column) 21 | assert_match(/Expected closing tag for