diff --git a/README.md b/README.md index 090fdbe..6b27372 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,17 @@ print({ // => "" ``` +`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";`, + ` `, + `}`, + ].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"; + + }" + `); + }); + + 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;"`); + }); +});