diff --git a/lib/syntax_tree/erb/format.rb b/lib/syntax_tree/erb/format.rb index 66dd397..d5e8b18 100644 --- a/lib/syntax_tree/erb/format.rb +++ b/lib/syntax_tree/erb/format.rb @@ -7,6 +7,7 @@ class Format < Visitor def initialize(q) @q = q + @inside_html_attributes = false end # Visit a Token node. @@ -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 @@ -92,11 +106,11 @@ 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) @@ -104,27 +118,42 @@ def visit_erb_case(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 @@ -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) @@ -219,6 +219,7 @@ def visit_opening_tag(node) visit(node.closing) end + @inside_html_attributes = false end # Visit an HtmlNode::ClosingTag node. @@ -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 diff --git a/lib/syntax_tree/erb/nodes.rb b/lib/syntax_tree/erb/nodes.rb index 570f0ec..da3158f 100644 --- a/lib/syntax_tree/erb/nodes.rb +++ b/lib/syntax_tree/erb/nodes.rb @@ -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 diff --git a/lib/syntax_tree/erb/parser.rb b/lib/syntax_tree/erb/parser.rb index eca23bf..0226dfd 100644 --- a/lib/syntax_tree/erb/parser.rb +++ b/lib/syntax_tree/erb/parser.rb @@ -19,6 +19,7 @@ def initialize(source) @source = source @tokens = make_tokens @found_doctype = false + @erb_context = :outside end def parse @@ -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 @@ -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 @@ -441,6 +455,8 @@ def parse_html_opening_tag ) end + @erb_context = :inside + attributes = many do atleast do @@ -448,6 +464,8 @@ def parse_html_opening_tag end end + @erb_context = :outside + closing = atleast do maybe { consume(:close) } || maybe { consume(:slash_close) } @@ -812,6 +830,8 @@ def parse_html_string ) end + @erb_context = :string + contents = many do atleast do @@ -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) diff --git a/lib/syntax_tree/erb/pretty_print.rb b/lib/syntax_tree/erb/pretty_print.rb index 0a67a91..529ddf2 100644 --- a/lib/syntax_tree/erb/pretty_print.rb +++ b/lib/syntax_tree/erb/pretty_print.rb @@ -51,7 +51,7 @@ def visit_erb(node) end if node.content q.breakable - q.text("content") + visit(node.content) end q.breakable @@ -67,6 +67,7 @@ def visit_erb_block(node) q.text("(erb_block") q.nest(2) do q.breakable + visit(node.opening) q.seplist(node.elements) { |child_node| visit(child_node) } end q.breakable diff --git a/test/erb_test.rb b/test/erb_test.rb index 9b11021..b671bfe 100644 --- a/test/erb_test.rb +++ b/test/erb_test.rb @@ -111,15 +111,49 @@ def test_if_and_end_in_same_output_tag_short def test_if_and_end_in_same_tag source = "Hello\n<% if true then this elsif false then that else maybe end %>\n