diff --git a/javascript/packages/formatter/src/format-printer.ts b/javascript/packages/formatter/src/format-printer.ts index 173de5757..c28e47923 100644 --- a/javascript/packages/formatter/src/format-printer.ts +++ b/javascript/packages/formatter/src/format-printer.ts @@ -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) } diff --git a/javascript/packages/formatter/test/erb/multiline.test.ts b/javascript/packages/formatter/test/erb/multiline.test.ts new file mode 100644 index 000000000..197375762 --- /dev/null +++ b/javascript/packages/formatter/test/erb/multiline.test.ts @@ -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 + +// 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` +
  • "> + <%= + link_to( + sanitize(t("views.pagination.first")), + url, + remote:, + class: "page-link", + tabindex: current_page.first? ? -1 : nil + ) + %> +
  • + `) + }) + + 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 = [ + `
    `, + `<%=`, + `link_to(`, + ` "First",`, + ` url`, + `)`, + `%>`, + `
    `, + ].join("\n") + + const result = formatter.format(source) + + expect(result).toEqual(dedent` +
    + <%= + link_to( + "First", + url + ) + %> +
    + `) + }) + + test("preserves relative indentation of content lines", () => { + expectFormattedToMatch(dedent` + <%= + content_tag( + :span, + title, + data: { + controller: "tooltip" + } + ) + %> + `) + }) + + test("is idempotent across multiple passes", () => { + expectFormattedToMatch( + dedent` +
  • + <%= + link_to( + "First", + url + ) + %> +
  • + `, + { 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? %> + First + <% end %> + ` + const result = formatter.format(source) + + expect(result).toEqual(dedent` + <% if current_page.first? %> + First + <% end %> + `) + }) + + test("squashes multi-line ERB inside attribute values", () => { + const source = dedent` +
  • ">First
  • + ` + const result = formatter.format(source) + + expect(result).toEqual(dedent` +
  • "> + First +
  • + `) + }) + + test("does not expand ERB tags in inline text flow", () => { + expectFormattedToMatch(dedent` +

    Showing <%= offset %> of <%= total %> results

    + `) + }) + + test("expanded output tags coexist with heredoc handling (issue 476)", () => { + expectFormattedToMatch(dedent` + <%= <<~HTML.html_safe + First + HTML + %> + `) + }) + + test("does not re-indent heredoc bodies inside expanded tags", () => { + expectFormattedToMatch(dedent` + <%= + tag.pre(<<~TEXT) + significant + leading + whitespace + TEXT + %> + `) + }) + }) +})