From 1458247898f74bb3e07b614711732c3c4eb0e822 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent Date: Sun, 12 Jul 2026 20:10:46 -0400 Subject: [PATCH] print(File): preserve blank-line separation between statements Reprinting a whole file previously joined every statement with a single newline, losing the blank lines that separate import groups, logical sections, and class members. Downstream that breaks lint rules in the consuming project (simple-import-sort group separation, import/newline-after-import, padding-line-between-statements) since prettier preserves blank lines but never invents them. toTree now attaches the original source text to the File node, and the statement-list join sites (Program, block/static/class bodies, switch cases, TS type/interface/module bodies, enum members) go through a printStatements helper: while a File with a known source is being printed, a gap of two-or-more newlines between consecutive nodes in the original source is preserved as one blank line (runs collapse to one). Standalone print(node) is unchanged -- without a File in flight the helper falls straight through to the old map/join (measured at parity with main: +0.5% / +0.7% / +3.7% across three interleaved runs of the 4k-line micro-benchmark, within the harness's noise). Co-Authored-By: Claude Fable 5 --- src/index.d.ts | 2 + src/parse.js | 2 + src/print.js | 72 ++++++++++++++++++++++++--- tests/print-file.test.js | 104 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 8 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index 6496615..17fdd19 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -14,6 +14,8 @@ export interface FileNode extends ASTNode { type: "File"; program: ASTNode; comments: ASTNode[]; + /** The original source text; `print` uses it to preserve blank lines. */ + source?: string; } export interface TemplateResult { diff --git a/src/parse.js b/src/parse.js index 3125858..ed6f0d1 100644 --- a/src/parse.js +++ b/src/parse.js @@ -110,6 +110,8 @@ export function toTree(source, options = {}) { type: "File", program: oxcResult.program, comments: oxcResult.comments || [], + // the original text, so `print` can preserve blank-line layout + source, start: oxcResult.program.start, end: oxcResult.program.end, }, diff --git a/src/print.js b/src/print.js index e059338..36ea000 100644 --- a/src/print.js +++ b/src/print.js @@ -12,6 +12,59 @@ function printComment(comment) { // restores it so nested calls stay well-behaved. let fileComments = null; let commentCursor = 0; +// Original source of the File being printed (when available) — used to +// preserve blank-line separation between statements. +let fileSource = null; + +/** + * Joins printed statements (or class/interface/enum members, switch + * cases, ...) with newlines. While printing a File whose original source + * is known, a gap of two-or-more newlines between consecutive nodes in + * that source is preserved as one blank line; standalone printing joins + * with single newlines exactly as before. + * + * @param {Array | null | undefined} nodes + * @return {string} + */ +function printStatements(nodes) { + if (!nodes || nodes.length === 0) return ""; + if (fileSource === null) return nodes.map(print).join("\n"); + + let out = ""; + let previous = null; + + for (const node of nodes) { + const printed = print(node); + + if (previous !== null) { + out += hasBlankLineBetween(previous, node) ? "\n\n" : "\n"; + } + + out += printed; + previous = node; + } + + return out; +} + +/** + * Whether the original source separates `a` and `b` with at least one + * blank line (i.e. two newlines between `a`'s end and `b`'s start). + * + * @param {object} a + * @param {object} b + * @return {boolean} + */ +function hasBlankLineBetween(a, b) { + if (typeof a.end !== "number" || typeof b.start !== "number" || b.start <= a.end) { + return false; + } + + const between = fileSource.slice(a.end, b.start); + const firstNewline = between.indexOf("\n"); + + return firstNewline !== -1 && between.indexOf("\n", firstNewline + 1) !== -1; +} /** * Prints and consumes any not-yet-emitted comments that start before @@ -48,6 +101,7 @@ function flushCommentsBefore(position) { function printFile(file) { const previousComments = fileComments; const previousCursor = commentCursor; + const previousSource = fileSource; // `filter` already yields a fresh array, so sorting in place is safe and // the caller's `file.comments` is never mutated. (oxc emits comments @@ -60,6 +114,7 @@ function printFile(file) { fileComments = comments?.length ? comments : null; commentCursor = 0; + fileSource = typeof file.source === "string" ? file.source : null; try { let output = print(file.program); @@ -73,6 +128,7 @@ function printFile(file) { } finally { fileComments = previousComments; commentCursor = previousCursor; + fileSource = previousSource; } } @@ -313,7 +369,7 @@ export function print(node) { case "BlockStatement": case "StaticBlock": { - const body = (node.body ?? []).map(print).join("\n"); + const body = printStatements(node.body); return braceBlock(body); } @@ -354,13 +410,13 @@ export function print(node) { case "SwitchStatement": { const disc = print(node.discriminant); - const cases = (node.cases ?? []).map(print).join("\n"); + const cases = printStatements(node.cases); return `switch (${disc}) ${braceBlock(cases)}`; } case "SwitchCase": { const test = node.test ? `case ${print(node.test)}:` : "default:"; - const body = (node.consequent ?? []).map(print).join("\n"); + const body = printStatements(node.consequent); return body ? `${test}\n${indent(body)}` : test; } @@ -434,7 +490,7 @@ export function print(node) { } case "ClassBody": { - const body = (node.body ?? []).map(print).join("\n"); + const body = printStatements(node.body); return braceBlock(body); } @@ -733,7 +789,7 @@ export function print(node) { // ── TypeScript: object types & signatures ────────────────────── case "TSTypeLiteral": { - const members = (node.members ?? []).map(print).join("\n"); + const members = printStatements(node.members); return `{\n${members}\n}`; } @@ -782,7 +838,7 @@ export function print(node) { } case "TSInterfaceBody": { - const body = (node.body ?? []).map(print).join("\n"); + const body = printStatements(node.body); return braceBlock(body); } @@ -828,7 +884,7 @@ export function print(node) { } case "TSModuleBlock": { - const body = (node.body ?? []).map(print).join("\n"); + const body = printStatements(node.body); return braceBlock(body); } @@ -1037,7 +1093,7 @@ export function print(node) { // ── Program (root) ───────────────────────────────────────────── case "Program": - return (node.body ?? []).map(print).join("\n"); + return printStatements(node.body); default: throw new Error(`ember-estree print: unsupported node type '${node.type}'`); diff --git a/tests/print-file.test.js b/tests/print-file.test.js index a301f47..f4c3eef 100644 --- a/tests/print-file.test.js +++ b/tests/print-file.test.js @@ -40,6 +40,7 @@ describe("print(File)", () => { expect(print(tree)).toMatchInlineSnapshot(` "/* setup */ const a = 1; + /** * doc comment */ @@ -109,3 +110,106 @@ describe("print(File)", () => { expect(print(tree)).toMatchInlineSnapshot(`"const a = 1;"`); }); }); + +describe("print(File) blank-line preservation", () => { + it("keeps blank lines between top-level statements", () => { + const source = [`const a = 1;`, ``, `const b = 2;`, `const c = 3;`].join("\n"); + const tree = toTree(source, { filePath: "m.js" }); + + expect(print(tree)).toMatchInlineSnapshot(` + "const a = 1; + + const b = 2; + const c = 3;" + `); + }); + + it("keeps blank lines between import groups", () => { + const source = [ + `import { getOwner } from "@ember/owner";`, + `import * as QUnit from "qunit";`, + ``, + `import Application from "#app/app.js";`, + `import config from "#config";`, + ``, + `export function start() {`, + ` QUnit.start();`, + `}`, + ].join("\n"); + const tree = toTree(source, { filePath: "m.js" }); + + expect(print(tree)).toMatchInlineSnapshot(` + "import { getOwner } from "@ember/owner"; + import * as QUnit from "qunit"; + + import Application from "#app/app.js"; + import config from "#config"; + + export function start() { + QUnit.start(); + }" + `); + }); + + it("keeps blank lines inside function bodies and class bodies", () => { + const source = [ + `class Foo {`, + ` a = 1;`, + ``, + ` method() {`, + ` const x = 1;`, + ``, + ` return x;`, + ` }`, + `}`, + ].join("\n"); + const tree = toTree(source, { filePath: "m.js" }); + + expect(print(tree)).toMatchInlineSnapshot(` + "class Foo { + a = 1; + + method() { + const x = 1; + + return x; + } + }" + `); + }); + + it("keeps a blank line that precedes a comment", () => { + const source = [`const a = 1;`, ``, `// setup`, `const b = 2;`].join("\n"); + const tree = toTree(source, { filePath: "m.js" }); + + expect(print(tree)).toMatchInlineSnapshot(` + "const a = 1; + + // setup + const b = 2;" + `); + }); + + it("collapses runs of blank lines to a single blank line", () => { + const source = [`const a = 1;`, ``, ``, ``, `const b = 2;`].join("\n"); + const tree = toTree(source, { filePath: "m.js" }); + + expect(print(tree)).toMatchInlineSnapshot(` + "const a = 1; + + const b = 2;" + `); + }); + + it("does not affect standalone printing (no File in flight)", () => { + const source = [`const a = 1;`, ``, `const b = 2;`].join("\n"); + const tree = toTree(source, { filePath: "m.js" }); + + // printing the bare Program has no source attached, so statements + // join with single newlines exactly as before + expect(print(tree.program)).toMatchInlineSnapshot(` + "const a = 1; + const b = 2;" + `); + }); +});