Skip to content

Commit

Permalink
Support for more syntax and better multiline ERB formatting (#92)
Browse files Browse the repository at this point in the history
* Several improvements

- Fix alignment in multiline Ruby, at the cost of making the code less
  concise
- Don't break the line between "<% if" and the condition
- Support <% when 1, 2 %>

The most controversial change is

<%=
  long_command(
    ...
  )
%>

instead of

<%= long_command(
  ...
) %>

* Support <div <%= erb %>>
  • Loading branch information
spect88 authored Sep 14, 2024
1 parent c7c5ef6 commit e87e074
Show file tree
Hide file tree
Showing 18 changed files with 369 additions and 111 deletions.
227 changes: 165 additions & 62 deletions lib/syntax_tree/erb/format.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Format < Visitor

def initialize(q)
@q = q
@inside_html_attributes = false
end

# Visit a Token node.
Expand All @@ -28,20 +29,33 @@ def visit_document(node)
q.breakable(force: true)
end

def visit_block(node)
visit(node.opening)
# Dependent block is one that follows after a "main one", e.g. <% else %>
def visit_block(node, dependent: false)
process =
proc do
visit(node.opening)

breakable = breakable_inside(node)
if node.elements.any?
q.indent do
q.breakable if breakable
handle_child_nodes(node.elements)
breakable = breakable_inside(node)
if node.elements.any?
q.indent do
q.breakable("") if breakable
handle_child_nodes(node.elements)
end
end

if node.closing
q.breakable("") if breakable
visit(node.closing)
end
end
end

if node.closing
q.breakable("") if breakable
visit(node.closing)
if dependent
process.call
else
q.group do
q.break_parent unless @inside_html_attributes
process.call
end
end
end

Expand Down Expand Up @@ -92,39 +106,54 @@ def visit_erb_if(node)
end

def visit_erb_elsif(node)
visit_block(node)
visit_block(node, dependent: true)
end

def visit_erb_else(node)
visit_block(node)
visit_block(node, dependent: true)
end

def visit_erb_case(node)
visit_block(node)
end

def visit_erb_case_when(node)
visit_block(node)
visit_block(node, dependent: true)
end

# Visit an ErbNode node.
def visit_erb(node)
visit(node.opening_tag)

if node.keyword
q.text(" ")
visit(node.keyword)
q.group do
if !node.keyword && node.content.blank?
q.text(" ")
elsif node.keyword && node.content.blank?
q.text(" ")
visit(node.keyword)
q.text(" ")
else
visit_erb_content(node.content, keyword: node.keyword)
q.breakable unless node.closing_tag.is_a?(ErbDoClose)
end
end

node.content.blank? ? q.text(" ") : visit(node.content)

visit(node.closing_tag)
end

def visit_erb_do_close(node)
closing = node.closing.value.end_with?("-%>") ? "-%>" : "%>"
q.text(node.closing.value.gsub(closing, "").rstrip)
q.text(" ")
# Append the "do" at the end of Ruby code (within the same group)
last_erb_content_group = q.current_group.contents.last
last_erb_content_indent = last_erb_content_group.contents.last
q.with_target(last_erb_content_indent.contents) do
q.text(" ")
q.text(node.closing.value.gsub(closing, "").rstrip)
end

# Add a breakable space after the indent, but within the same group
q.with_target(last_erb_content_group.contents) { q.breakable }

q.text(closing)
end

Expand All @@ -145,55 +174,26 @@ def visit_erb_end(node)
visit(node.closing_tag)
end

def visit_erb_content(node)
def visit_erb_content(node, keyword: nil)
# Reject all VoidStmt to avoid empty lines
nodes =
(node.value&.statements&.child_nodes || []).reject do |node|
node.is_a?(SyntaxTree::VoidStmt)
end

if nodes.size == 1
q.text(" ")
format_statement(nodes.first)
q.text(" ")
elsif nodes.size > 1
q.indent do
q.breakable("")
q.seplist(nodes, -> { q.breakable("") }) do |child_node|
format_statement(child_node)
end
end
nodes = child_nodes_without_void_statements(node)
return if nodes.empty?

q.indent do
q.breakable
end
end

def format_statement(statement)
formatter =
SyntaxTree::Formatter.new("", [], SyntaxTree::ERB::MAX_WIDTH)

formatter.format(statement)
formatter.flush

formatted =
formatter.output.join.gsub(
SyntaxTree::ERB::ErbYield::PLACEHOLDER,
"yield"
)

output_rows(formatted.split("\n"))
end

def output_rows(rows)
if rows.size > 1
q.seplist(rows, -> { q.breakable("") }) { |row| q.text(row) }
elsif rows.size == 1
q.text(rows.first)
q.seplist(nodes, -> { q.breakable(force: true) }) do |child_node|
code =
format_statement_with_keyword_prefix(child_node, keyword: keyword)
output_rows(code.split("\n"))
# Pass the keyword only to the first child node
keyword = nil
end
end
end

# Visit an HtmlNode::OpeningTag node.
def visit_opening_tag(node)
@inside_html_attributes = true
q.group do
visit(node.opening)
visit(node.name)
Expand All @@ -219,6 +219,7 @@ def visit_opening_tag(node)

visit(node.closing)
end
@inside_html_attributes = false
end

# Visit an HtmlNode::ClosingTag node.
Expand Down Expand Up @@ -383,6 +384,108 @@ def node_should_group(node)
node.is_a?(SyntaxTree::ERB::CharData) ||
node.is_a?(SyntaxTree::ERB::ErbNode)
end

def child_nodes_without_void_statements(node)
(node.value&.statements&.child_nodes || []).reject do |node|
node.is_a?(SyntaxTree::VoidStmt)
end
end

def format_statement_with_keyword_prefix(statement, keyword: nil)
case keyword&.value
when nil
format_statement(statement)
when "if"
statement =
SyntaxTree::IfNode.new(
predicate: statement,
statements: void_body,
consequent: nil,
location: keyword.location
)
format_statement(statement).delete_suffix("\nend")
when "unless"
statement =
SyntaxTree::UnlessNode.new(
predicate: statement,
statements: void_body,
consequent: nil,
location: keyword.location
)
format_statement(statement).delete_suffix("\nend")
when "elsif"
statement =
SyntaxTree::Elsif.new(
predicate: statement,
statements: void_body,
consequent: nil,
location: keyword.location
)
format_statement(statement).delete_suffix("\nend")
when "case"
statement =
SyntaxTree::Case.new(
keyword:
SyntaxTree::Kw.new(value: "case", location: keyword.location),
value: statement,
consequent: void_body,
location: keyword.location
)
format_statement(statement).delete_suffix("\nend")
when "when"
statement =
SyntaxTree::When.new(
arguments: statement.contents,
statements: void_body,
consequent: nil,
location: keyword.location
)
format_statement(statement).delete_suffix("\nend")
else
q.text(keyword.value)
q.breakable
format_statement(statement)
end
end

def format_statement(statement)
formatter =
SyntaxTree::Formatter.new("", [], SyntaxTree::ERB::MAX_WIDTH)

formatter.format(statement)
formatter.flush

formatter.output.join.gsub(
SyntaxTree::ERB::ErbYield::PLACEHOLDER,
"yield"
)
end

def output_rows(rows)
if rows.size > 1
q.seplist(rows, -> { q.breakable(force: true) }) { |row| q.text(row) }
elsif rows.size == 1
q.text(rows.first)
end
end

def fake_location
Location.new(
start_line: 0,
start_char: 0,
start_column: 0,
end_line: 0,
end_char: 0,
end_column: 0
)
end

def void_body
SyntaxTree::Statements.new(
body: [SyntaxTree::VoidStmt.new(location: fake_location)],
location: fake_location
)
end
end
end
end
9 changes: 7 additions & 2 deletions lib/syntax_tree/erb/nodes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,15 @@ def prepare_content(content)
if content.is_a?(ErbContent)
content
else
# Set content to nil if it is empty
content ||= []

ErbContent.new(value: content)
if !content.empty? && keyword&.value == "when"
# "when" accepts multiple comma-separated arguments, so let's try
# to make them parsable.
ErbContent.new(value: ["[", *content, "]"])
else
ErbContent.new(value: content)
end
end
rescue SyntaxTree::Parser::ParseError
# Try to add the keyword to see if it parses
Expand Down
26 changes: 24 additions & 2 deletions lib/syntax_tree/erb/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def initialize(source)
@source = source
@tokens = make_tokens
@found_doctype = false
@erb_context = :outside
end

def parse
Expand Down Expand Up @@ -300,7 +301,7 @@ def make_tokens
# strong, vue-component-kebab, VueComponentPascal
# abc, #abc, @abc, :abc
enum.yield :name, $&, index, line, column_index
when /\A<%/
when /\A<%={1,2}/, /\A<%-/, /\A<%/
# the beginning of an ERB tag
# <%
enum.yield :erb_open, $&, index, line, column_index
Expand Down Expand Up @@ -419,7 +420,20 @@ def parse_until_erb(classes:)
items = []

loop do
result = parse_any_tag
result =
case @erb_context
when :string
atleast do
maybe { consume(:text) } || maybe { consume(:whitespace) } ||
maybe { parse_erb_tag }
end
when :inside
atleast do
maybe { parse_erb_tag } || maybe { parse_html_attribute }
end
when :outside
parse_any_tag
end
items << result
break if classes.any? { |cls| result.is_a?(cls) }
end
Expand All @@ -441,13 +455,17 @@ def parse_html_opening_tag
)
end

@erb_context = :inside

attributes =
many do
atleast do
maybe { parse_erb_tag } || maybe { parse_html_attribute }
end
end

@erb_context = :outside

closing =
atleast do
maybe { consume(:close) } || maybe { consume(:slash_close) }
Expand Down Expand Up @@ -812,6 +830,8 @@ def parse_html_string
)
end

@erb_context = :string

contents =
many do
atleast do
Expand All @@ -820,6 +840,8 @@ def parse_html_string
end
end

@erb_context = :inside

closing =
if opening.type == :string_open_double_quote
consume(:string_close_double_quote)
Expand Down
Loading

0 comments on commit e87e074

Please sign in to comment.