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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ print({
// => "<template>Hello</template>"
```

`print` also accepts the `File` node returned by `toTree`, printing the whole program with `file.comments` woven back into the output (each comment is emitted before the nearest node that follows it in the original source). Placement is approximate — a trailing same-line comment becomes a leading comment of the next node — so pair the output with a formatter (e.g. prettier) when exact layout matters.

```js
import { toTree, print } from "ember-estree";

let tree = toTree(`// greet the user\nlet greeting = "hello";`);

print(tree);
// => '// greet the user\nlet greeting = "hello";'
```

## Options

Both `toTree` and `parse` accept an options object as their second argument.
Expand Down
101 changes: 100 additions & 1 deletion src/print.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,81 @@
/**
* Prints a comment as it appeared in source.
* @param {object} comment - `{ type: "Line" | "Block", value }`
* @return {string}
*/
function printComment(comment) {
return comment.type === "Block" ? `/*${comment.value}*/` : `//${comment.value}`;
}

// Comment-weaving state for `print(File)`. `print` is synchronous and
// single-threaded, so module-level state is safe; `printFile` saves and
// restores it so nested calls stay well-behaved.
let fileComments = null;
let commentCursor = 0;

/**
* Prints and consumes any not-yet-emitted comments that start before
* `position` in the original source.
*
* `fileComments` is sorted and `commentCursor` only ever advances, so the
* total flushing work across an entire `print(File)` is O(comments) — each
* comment is visited exactly once, no matter how many nodes are printed.
*
* @param {number} position
* @return {string}
*/
function flushCommentsBefore(position) {
let out = "";
while (commentCursor < fileComments.length && fileComments[commentCursor].start < position) {
out += `${printComment(fileComments[commentCursor])}\n`;
commentCursor += 1;
}
return out;
}

/**
* Prints a File node: its program, with `file.comments` woven back in
* before the nearest printed node that follows them in the original
* source (and any remaining comments appended at the end of the file).
*
* Placement is approximate -- a same-line trailing comment becomes a
* leading comment of the next node -- so pair the output with a formatter
* (e.g. prettier) when exact layout matters.
*
* @param {object} file
* @return {string}
*/
function printFile(file) {
const previousComments = fileComments;
const previousCursor = commentCursor;

// `filter` already yields a fresh array, so sorting in place is safe and
// the caller's `file.comments` is never mutated. (oxc emits comments
// pre-sorted, making the sort a cheap single pass.)
const comments = file.comments?.length
? file.comments
.filter((comment) => typeof comment.start === "number")
.sort((a, b) => a.start - b.start)
: null;

fileComments = comments?.length ? comments : null;
commentCursor = 0;

try {
let output = print(file.program);
const trailing = fileComments ? flushCommentsBefore(Infinity) : "";

if (trailing) {
output = output ? `${output}\n${trailing}` : trailing;
}

return output;
} finally {
fileComments = previousComments;
commentCursor = previousCursor;
}
}

/**
* Recursive AST printer that handles ESTree, TypeScript, and
* Glimmer template node types.
Expand All @@ -6,7 +84,9 @@
*
* Tools like zmod use span-based patching (preserving the original source
* for unchanged regions), so this printer is typically only invoked for
* newly-created AST nodes (via builders).
* newly-created AST nodes (via builders) — with one exception: a `File`
* node (as returned by `toTree`) is printed in full, with its `comments`
* woven back into the output.
*
* @param {object} node - The AST node to print
* @return {string}
Expand All @@ -15,7 +95,26 @@ export function print(node) {
if (!node) return "";
if (typeof node === "string") return node;

// Comment weaving — active only while a comment-carrying `print(File)`
// is in flight. When any comments start before this node in the original
// source, emit them first, then re-enter (the cursor has advanced past
// them, so the recursion falls straight through to the switch). Outside
// of `print(File)` the guard short-circuits on its first check, so
// standalone printing pays a single null test.
if (
fileComments !== null &&
commentCursor < fileComments.length &&
typeof node.start === "number" &&
fileComments[commentCursor].start < node.start
) {
return flushCommentsBefore(node.start) + print(node);
}

switch (node.type) {
// ── File (root of `toTree`) ───────────────────────────────────
case "File":
return printFile(node);

// ── Identifiers & Literals ────────────────────────────────────
case "Identifier":
return printTypeAnnotated(node.name, node);
Expand Down
111 changes: 111 additions & 0 deletions tests/print-file.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { toTree, print } from "../src/index.js";

describe("print(File)", () => {
it("prints the program of a File node", () => {
const tree = toTree(`const a = 1;\nconst b = 2;`, { filePath: "m.js" });

expect(print(tree)).toMatchInlineSnapshot(`
"const a = 1;
const b = 2;"
`);
});

it("prints an empty File", () => {
const tree = toTree(``, { filePath: "m.js" });

expect(print(tree)).toMatchInlineSnapshot(`""`);
});

it("weaves leading comments back in", () => {
const tree = toTree(`// top comment\nconst a = 1;`, { filePath: "m.js" });

expect(print(tree)).toMatchInlineSnapshot(`
"// top comment
const a = 1;"
`);
});

it("weaves block and doc comments back in", () => {
const source = [
`/* setup */`,
`const a = 1;`,
`/**`,
` * doc comment`,
` */`,
`function f() {}`,
].join("\n");
const tree = toTree(source, { filePath: "m.js" });

expect(print(tree)).toMatchInlineSnapshot(`
"/* setup */
const a = 1;
/**
* doc comment
*/
function f() {}"
`);
});

it("weaves comments nested inside functions and classes, with indentation", () => {
const source = [`function f() {`, ` // inner comment`, ` return 2;`, `}`].join("\n");
const tree = toTree(source, { filePath: "m.js" });

expect(print(tree)).toMatchInlineSnapshot(`
"function f() {
// inner comment
return 2;
}"
`);
});

it("appends comments trailing the last statement", () => {
const tree = toTree(`const a = 1;\n// trailing`, { filePath: "m.js" });

expect(print(tree)).toMatchInlineSnapshot(`
"const a = 1;
// trailing
"
`);
});

it("keeps comments in gjs files alongside templates", () => {
const source = [
`// gjs comment`,
`import Component from "@glimmer/component";`,
`export default class D extends Component {`,
` /** field doc */`,
` name = "x";`,
` <template>Hi {{this.name}}</template>`,
`}`,
].join("\n");
const tree = toTree(source, { filePath: "m.gjs" });

expect(print(tree)).toMatchInlineSnapshot(`
"// gjs comment
import Component from "@glimmer/component";
export default class D extends Component {
/** field doc */
name = "x";
<template>Hi {{this.name}}</template>
}"
`);
});

it("does not leak comment state into later print calls", () => {
const tree = toTree(`// only comment\nconst a = 1;`, { filePath: "m.js" });

print(tree);

// a later, unrelated print must not emit this file's comments
expect(print({ type: "Identifier", name: "foo" })).toMatchInlineSnapshot(`"foo"`);
});

it("prints a File with no comments property", () => {
const tree = toTree(`const a = 1;`, { filePath: "m.js" });

delete tree.comments;

expect(print(tree)).toMatchInlineSnapshot(`"const a = 1;"`);
});
});
Loading