diff --git a/README.md b/README.md
index 090fdbe..6b27372 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,17 @@ print({
// => "Hello"
```
+`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.
diff --git a/src/print.js b/src/print.js
index 0e25064..e059338 100644
--- a/src/print.js
+++ b/src/print.js
@@ -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.
@@ -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}
@@ -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);
diff --git a/tests/print-file.test.js b/tests/print-file.test.js
new file mode 100644
index 0000000..a301f47
--- /dev/null
+++ b/tests/print-file.test.js
@@ -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";`,
+ ` Hi {{this.name}}`,
+ `}`,
+ ].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";
+ Hi {{this.name}}
+ }"
+ `);
+ });
+
+ 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;"`);
+ });
+});