Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
72 changes: 64 additions & 8 deletions src/print.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<object> | 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
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -73,6 +128,7 @@ function printFile(file) {
} finally {
fileComments = previousComments;
commentCursor = previousCursor;
fileSource = previousSource;
}
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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}`;
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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}'`);
Expand Down
104 changes: 104 additions & 0 deletions tests/print-file.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe("print(File)", () => {
expect(print(tree)).toMatchInlineSnapshot(`
"/* setup */
const a = 1;

/**
* doc comment
*/
Expand Down Expand Up @@ -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;"
`);
});
});
Loading