Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions javascript/packages/formatter/src/format-printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,11 +1066,71 @@ export class FormatPrinter extends Printer implements TextFlowDelegate, Attribut
visitERBContentNode(node: ERBContentNode) {
if (isERBCommentNode(node)) {
this.visitERBCommentNode(node)
} else if (!this.inlineMode && this.shouldExpandERBContent(node)) {
this.printExpandedERBNode(node)
} else {
this.printERBNode(node)
}
}

/**
* An ERB content tag is kept expanded (delimiters on their own lines) when
* the author placed a newline directly after the opening tag and the
* content spans multiple lines, mirroring how Prettier preserves
* user-authored expansion of object literals.
*
* Non-squiggly heredocs (`<<` and `<<-`) are excluded: their bodies are
* whitespace-significant, so re-indenting them would change the string
* value. Those fall back to the default single-header rendering.
*
* @todo revisit once we have access to Prism nodes
*/
private shouldExpandERBContent(node: ERBContentNode): boolean {
const content = node.content?.value ?? ""

if (!/^[ \t]*\r?\n/.test(content)) return false
if (!content.trim().includes("\n")) return false

return !/<<(?!~)-?['"`]?[A-Za-z_]/.test(content)
}

/**
* Print an ERB tag expanded across multiple lines:
*
* <%=
* content
* %>
*
* Content lines are dedented by their common leading whitespace, then
* re-indented one level below the tag.
*/
private printExpandedERBNode(node: ERBContentNode) {
const open = node.tag_opening?.value ?? "<%"
const close = node.tag_closing?.value ?? "%>"
const content = node.content?.value ?? ""

const lines = content.replace(/\r\n/g, "\n").split("\n").map(line => line.trimEnd())

while (lines.length > 0 && lines[0] === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()

const commonIndent = lines
.filter(line => line !== "")
.reduce((minimum, line) => Math.min(minimum, line.match(/^[ \t]*/)![0].length), Infinity)

const dedentedLines = lines.map(line => line === "" ? "" : line.slice(commonIndent))

this.pushWithIndent(open)

this.withIndent(() => {
dedentedLines.forEach(line => {
this.push(line === "" ? "" : this.indent + line)
})
})

this.pushWithIndent(close)
}

visitERBOpenTagNode(node: ERBOpenTagNode) {
this.printERBNode(node)
}
Expand Down
231 changes: 231 additions & 0 deletions javascript/packages/formatter/test/erb/multiline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { describe, test, expect, beforeAll } from "vitest"
import { Herb } from "@herb-tools/node-wasm"
import { Formatter } from "../../src"
import { createExpectFormattedToMatch } from "../helpers"

import dedent from "dedent"

let formatter: Formatter
let expectFormattedToMatch: ReturnType<typeof createExpectFormattedToMatch>

// https://github.com/marcoroth/herb/issues/1835
//
// When the source has a newline immediately after the opening tag (`<%=` or
// `<%`), the formatter should keep the tag expanded with the opening and
// closing delimiters on their own lines, rather than squashing the first line
// of Ruby onto the opening delimiter. This mirrors how Prettier preserves
// user-authored expansion of object literals, and how the formatter already
// renders multi-line ERB comments.
describe("@herb-tools/formatter", () => {
beforeAll(async () => {
await Herb.load()

formatter = new Formatter(Herb, {
indentWidth: 2,
maxLineLength: 80,
})

expectFormattedToMatch = createExpectFormattedToMatch(formatter)
})

describe("multi-line ERB output tags (issue 1835)", () => {
test("preserves newline after opening tag at top-level", () => {
expectFormattedToMatch(dedent`
<%=
link_to(
sanitize(t("views.pagination.first")),
url,
remote:,
class: "page-link",
tabindex: current_page.first? ? -1 : nil
)
%>
`)
})

test("preserves newline after opening tag inside an element", () => {
expectFormattedToMatch(dedent`
<li class="<%= class_names("page-item user-select-none", disabled: current_page.first?) %>">
<%=
link_to(
sanitize(t("views.pagination.first")),
url,
remote:,
class: "page-link",
tabindex: current_page.first? ? -1 : nil
)
%>
</li>
`)
})

test("preserves newline after opening tag for non-output ERB tags", () => {
expectFormattedToMatch(dedent`
<%
pagination_options = {
remote:,
class: "page-link"
}
%>
`)
})

test("still squashes multi-line content without a leading newline", () => {
expectFormattedToMatch(dedent`
<%= link_to(
sanitize(t("views.pagination.first")),
url,
remote:
) %>
`)
})

test("balances a tag with a leading newline but inline closing delimiter", () => {
const source = dedent`
<%=
link_to(
"First",
url
) %>
`
const result = formatter.format(source)

expect(result).toEqual(dedent`
<%=
link_to(
"First",
url
)
%>
`)
})

test("re-indents content relative to the tag's indentation", () => {
const source = [
`<div>`,
`<%=`,
`link_to(`,
` "First",`,
` url`,
`)`,
`%>`,
`</div>`,
].join("\n")

const result = formatter.format(source)

expect(result).toEqual(dedent`
<div>
<%=
link_to(
"First",
url
)
%>
</div>
`)
})

test("preserves relative indentation of content lines", () => {
expectFormattedToMatch(dedent`
<%=
content_tag(
:span,
title,
data: {
controller: "tooltip"
}
)
%>
`)
})

test("is idempotent across multiple passes", () => {
expectFormattedToMatch(
dedent`
<li>
<%=
link_to(
"First",
url
)
%>
</li>
`,
{ passes: 3 },
)
})

test("single-line output tags are unaffected", () => {
expectFormattedToMatch(`<%= title %>`)
})

test("collapses a leading newline when content is a single line", () => {
const source = `<%=\n title %>`
const result = formatter.format(source)

expect(result).toEqual(`<%= title %>`)
})

test("does not expand control-flow opening tags", () => {
const source = dedent`
<%
if current_page.first? %>
<span>First</span>
<% end %>
`
const result = formatter.format(source)

expect(result).toEqual(dedent`
<% if current_page.first? %>
<span>First</span>
<% end %>
`)
})

test("squashes multi-line ERB inside attribute values", () => {
const source = dedent`
<li class="<%=
class_names(
"page-item",
disabled: current_page.first?
)
%>">First</li>
`
const result = formatter.format(source)

expect(result).toEqual(dedent`
<li class="<%= class_names( "page-item", disabled: current_page.first? ) %>">
First
</li>
`)
})
Comment thread
marcoroth marked this conversation as resolved.

test("does not expand ERB tags in inline text flow", () => {
expectFormattedToMatch(dedent`
<p>Showing <%= offset %> of <%= total %> results</p>
`)
})

test("expanded output tags coexist with heredoc handling (issue 476)", () => {
expectFormattedToMatch(dedent`
<%= <<~HTML.html_safe
<span>First</span>
HTML
%>
`)
})

test("does not re-indent heredoc bodies inside expanded tags", () => {
expectFormattedToMatch(dedent`
<%=
tag.pre(<<~TEXT)
significant
leading
whitespace
TEXT
%>
`)
})
})
})
Loading